Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90a705316 | ||
|
|
b5040cffab | ||
|
|
fb1895030f | ||
|
|
ca2532307f | ||
|
|
51dfd38f0b | ||
|
|
c6eb4564d2 | ||
|
|
a3364e82e1 | ||
|
|
a582934f6a | ||
|
|
a7bf07bb1c | ||
|
|
3b81f99732 | ||
|
|
7cf63f4858 | ||
|
|
c0e9f5c8a5 | ||
|
|
3fd2416a30 | ||
|
|
5305cf21f9 | ||
|
|
b9c828f092 | ||
|
|
4490e7053b | ||
|
|
ad65d4833e | ||
|
|
7d2f65ad42 | ||
|
|
225c52710a | ||
|
|
56e6713292 | ||
|
|
ec7f5c4e57 | ||
|
|
1d63e882bc | ||
|
|
cf1b693e85 | ||
|
|
454b77215f | ||
|
|
253a0aa56e | ||
|
|
ba75718ac3 | ||
|
|
e3394655b3 | ||
|
|
595e34b2d1 | ||
|
|
8f325de979 | ||
|
|
515b3206b9 | ||
|
|
fce9459e42 | ||
|
|
47fe24e07c | ||
|
|
6359d65999 | ||
|
|
179473e5d5 | ||
|
|
ec378e82c9 | ||
|
|
0e1e2e87de | ||
|
|
342a9a4edf | ||
|
|
5f6065344f | ||
|
|
a47a4683f3 | ||
|
|
8b829647f4 | ||
|
|
0fc162e8cd | ||
|
|
a6fb39ba9f | ||
|
|
9410913da9 | ||
|
|
bd61ef532f | ||
|
|
015075077c | ||
|
|
263a72483a | ||
|
|
5cd72d2df3 | ||
|
|
e4c7c8fe9b | ||
|
|
9c21fa4e4f | ||
|
|
50e2998a20 | ||
|
|
4243da507c | ||
|
|
29cc4fd904 | ||
|
|
04e32212f7 | ||
|
|
bb496fde78 |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "05af786c",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# PR #70 (Issue #66) 리뷰 — head bb496fd
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
|
||||
- identity 확인: HEAD bb496fde785133ad1dbec288aa7431766efb6eb5, tree 15a72c9a1933c64018a2f2d9aa9707348905bb4d, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD 0461a63 그대로.
|
||||
- 범위: base 0461a63..bb496fd (커밋 1개). 바뀐 파일은 세 개다: `.agents/skills/lib_py/stall_watch.py`, `tests/conftest.py`, `tests/test_issue66_stall_watch_sanitized_name.py`.
|
||||
|
||||
## 1. 확인한 것과 근거
|
||||
|
||||
### 1) 완료 조건
|
||||
|
||||
herdr 를 부르는 곳은 모두 변환된 이름을 쓴다.
|
||||
- `HerdrSource.__init__` 이 `self.name = sanitize_herdr_agent_name(target)` 를 만든다 (`stall_watch.py:162`).
|
||||
- 이 이름을 쓰는 곳:
|
||||
- `agent get` (:183)
|
||||
- `agent read` (:206)
|
||||
- `agent prompt` (:211)
|
||||
- `watcher.wait_once(self.herdr_name, …)` (:331, 값은 :229)
|
||||
- `pane process-info` 와 `pane read` (:187, :193)는 `agent get` 이 돌려준 pane_id 를 쓰므로 이름과 상관이 없다.
|
||||
- `--herdr-session` 은 서버 이름으로만 쓰인다. `self.session` (:159) 과 `session=self.args.herdr_session` (:331) 이고, 변환되지 않는다. 둘을 섞어 쓴 곳은 없다.
|
||||
|
||||
로그와 알림은 원래 이름을 쓴다.
|
||||
- 로그 :373 (blocked), :397 (입력창 비어 있지 않음), :406 (idle) 은 모두 `self.args.target` 이다.
|
||||
- 알림 상세(`stalled:idle`, `stalled:blocked …`)에는 이름이 들어가지 않는다.
|
||||
- 회귀 테스트는 `agent {target} idle for` 와 `agent {target} is blocked` 가 원래 이름으로 나오는지 확인한다 (test :85, :106).
|
||||
|
||||
변환이 필요 없는 이름(`creator-claude-t`)은 그대로이고, 그 경우의 테스트도 통과한다 (test :68 의 PLAIN).
|
||||
|
||||
### 2) 회귀 테스트: base 에서 실패, head 에서 통과
|
||||
|
||||
- head: 7 passed.
|
||||
- base(0461a63) 사본에 head 의 새 테스트와 conftest 를 넣고 돌리면 5 failed, 2 passed 다.
|
||||
- 실패: LONG, UPPER 두 이름 각각의 idle nudge 와 blocked 보고 (4개), 긴 codex 이름의 종류 판정 (1개).
|
||||
- 통과: strict mock 자체를 확인하는 테스트와, 변환이 필요 없는 PLAIN.
|
||||
|
||||
### 3) 변환 함수가 lib.sh 와 같은 결과를 내는가
|
||||
|
||||
lib.sh `_sanitize_herdr_agent_name` (:293-316) 과 `lib_py.agents.sanitize.sanitize_herdr_agent_name` 에 같은 이름 10개를 넣어 비교했다.
|
||||
- 비교한 이름: 그대로 둘 이름, 대문자, 32자 초과(루프 세션 이름 포함), 정확히 32자, 33자, 숫자로 시작, `.`·공백·`@`, 비 ASCII `Ü`, `_` 로 시작.
|
||||
- 결과: 10개 모두 같고, 두 번 변환해도 바뀌지 않는다.
|
||||
- 그래서 호출자가 이미 변환된 이름을 넘겨도 안전하다.
|
||||
- 호출자는 원래 세션 이름을 넘긴다: `multi-agent-mux-delegate-job:431`, `:615` → `start_stall_watch "$AGENT_SESSION"` 와 `"$current_session"`.
|
||||
|
||||
### 4) resolve_agent 를 원래 이름으로 하는 것이 맞는가
|
||||
|
||||
맞다.
|
||||
- 긴 이름은 변환되면 끝이 해시로 바뀐다. 예: `mam-wt-issue-66-very-long-creator-codex` → `…-<sha8>`. 그래서 종류는 원래 접미사에서 읽어야 한다.
|
||||
- `HerdrSource` (:163) 과 `StallWatch` (:230) 둘 다 원래 이름을 쓴다.
|
||||
- 테스트 :111-129 가 `--agent ""` 일 때 codex 로 정해져 nudge 가 가는 것을 확인한다.
|
||||
- 종류를 변환된 이름에서 읽도록 바꾼 변이(m3)는 이 테스트에 잡힌다.
|
||||
|
||||
### 5) conftest `strict_agent_names`
|
||||
|
||||
- 기본(키 없음)일 때 `_match_agent` 는 예전과 똑같이 네 가지 비교를 한다 (`conftest.py` 수정분 :473-476). get/read 의 실패 출력도 예전 문구 그대로다. 그래서 기존 테스트의 동작은 바뀌지 않는다.
|
||||
- 켜면 등록된 이름과 정확히 같을 때만 답하고, 아니면 `{"error":{"code":"agent_not_found"}}` 를 stderr 에 쓰고 exit 1 한다. 실제 herdr 처럼 등록 이름(변환된 이름)으로만 찾는다.
|
||||
- 테스트 :51-65 가 get, read, wait, prompt 네 명령 모두 원래 이름에는 agent_not_found 를, 변환된 이름에는 rc 0 을 내는지 확인한다.
|
||||
|
||||
### 6) macOS 호환
|
||||
|
||||
- `/usr/bin/python3` 3.9.6 으로 `lib_py.stall_watch` import 와 `resolve_agent` 호출이 된다.
|
||||
- 새 코드에 `X | Y`, `match`, tomllib 가 없다. 셸은 바뀌지 않았다.
|
||||
|
||||
### 7) test_issue17_item4_decisions::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]
|
||||
|
||||
이 변경과 무관하다.
|
||||
- 이 테스트는 래퍼의 finalize 를 자체 shim(`PY=<shim>`)으로 돌린다. stall watcher 는 argv 를 기록하는 대역이다 (`tests/test_issue17_item4_decisions.py:218`).
|
||||
- conftest 의 mock herdr 와 `lib_py.stall_watch` 를 쓰지 않는다.
|
||||
- 판정 조건은 `took < 4.5` 와 terminate 뒤 레코드가 그대로인지 (:598-601) 로, 시간과 정리 단계에 의존한다.
|
||||
- 이 PR 이 바꾼 것(stall_watch 의 이름 변환, 기본 꺼짐인 mock 옵션)은 이 경로에 닿지 않는다.
|
||||
- 이 호스트에서도 통과했다(아래 표).
|
||||
|
||||
## 2. 실행한 테스트
|
||||
|
||||
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. .mam.env 를 export 하지 않았다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로 `tests/test_issue66_stall_watch_sanitized_name.py` | 7 passed |
|
||||
| base 사본 + 새 테스트·conftest | 5 failed, 2 passed |
|
||||
| 검증 경로: test_issue30_stall_watch, test_issue30_stall_watch_stop, test_issue17_item4_decisions, test_tier2_component, test_issue63_same_cwd_ids, test_issue50_dispatch_herdr, test_c1_tui_readiness (mock herdr 를 쓰는 인접 테스트) | 457 passed, 1 skipped, 0 failed |
|
||||
|
||||
변이 테스트 (head 사본, issue66 테스트):
|
||||
|
||||
| # | 변이 | 결과 |
|
||||
|---|---|---|
|
||||
| m1 | `wait_once` 를 원래 이름으로 | 잡힘 (5 failed) |
|
||||
| m2 | `agent prompt` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||
| m3 | 종류를 변환된 이름에서 판정 | 잡힘 (1 failed) |
|
||||
| m4 | blocked 로그를 변환된 이름으로 | 잡힘 (2 failed) |
|
||||
| m5 | `agent read` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||
|
||||
실제 herdr 서버(pid 19169 `herdr server`, 23492 `herdr --session multi-agent-mux server`)는 테스트 전후로 같다.
|
||||
|
||||
## 3. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 4. 비차단
|
||||
|
||||
- **N1**: 변환된 이름을 두 곳에서 따로 만든다(`HerdrSource.name` :162, `StallWatch.herdr_name` :229). 주입된 source 를 쓰는 테스트 때문에 둘 다 필요해 보인다. 결과가 같으므로 동작에는 문제가 없다.
|
||||
- **N2** (기존, 이 PR 과 무관): `--agent` 가 비어 있고 이름이 `…-claude-01` 처럼 숫자로 끝나면 `resolve_agent` 는 종류를 정하지 못한다(None). 실제 호출자는 항상 `--agent "$AGENT"` 를 넘기므로 지금은 영향이 없다.
|
||||
- **N3** (기존, 이 PR 과 무관): lib.sh 에 `_sanitize_herdr_agent_name` 이 두 벌 있다(:36, :293). sha 도구가 없을 때의 대처가 서로 다르다(:36 은 32자로 자르고, :293 은 python3 로 해시). 이 호스트처럼 shasum 이 있으면 두 벌 모두 Python 판과 같다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "1d2c21dc",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# PR #75 후속 라운드 리뷰 — 1d63e88..ec7f5c4 (review round 5)
|
||||
|
||||
리뷰어: planner-reviewer-claude-01 (claude) · job 1d2c21dc
|
||||
|
||||
## 0. 리뷰 경로 확인
|
||||
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`
|
||||
- HEAD `ec7f5c4e57286fb3c4238b8220f39094f4640391`, tree `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb` 로 [리뷰 identity] 와 같다.
|
||||
- `git status --porcelain --ignored` 는 비어 있다. `.mam` 은 없다.
|
||||
- 리뷰 전후로 확인했다. 본 체크아웃 main 은 `0e1e2e8` 그대로다.
|
||||
- 범위: 1d63e88..HEAD 의 커밋 `ec7f5c4` 하나다.
|
||||
- 바뀐 파일: `.agents/skills/lib.sh`(+7/−1), `tests/test_issue72_update.py`(+32/−3)
|
||||
- 브리프가 정한 범위(R2 와 테스트의 `pkill` 제거) 밖은 건드리지 않았다.
|
||||
|
||||
## 1. R2 — legacy `start_watchdog` 의 프로세스 그룹 분리
|
||||
|
||||
### 1.1 코드 (`lib.sh:2297-2310`)
|
||||
|
||||
- 모니터를 띄우는 `nohup bash … &` 직전에 `set -m` 을 켠다.
|
||||
- 켜기 전에 호출자의 `$-` 에 `m` 이 있었는지 `had_m` 에 기억한다.
|
||||
- `pid=$!` 를 읽은 뒤, 원래 꺼져 있었으면 `set +m` 으로 되돌린다.
|
||||
- job control 이 켜진 상태에서 띄운 백그라운드 잡은 자기 프로세스 그룹의 리더가 된다(PGID = PID). 그래서 다음 효과가 있다.
|
||||
- `$!` 는 그대로 그 PID 다. nohup 과 bash 가 exec 해도 PID 는 바뀌지 않는다.
|
||||
- 따라서 정확한 PID 보고(`echo "$pid"`, `:2314-2316`)와 `mam_monitor_started` 확인이 바뀌지 않는다.
|
||||
- 다음 기존 동작은 바뀌지 않았다.
|
||||
- 중복 방지(`_monitor_pids`, `:2291`)
|
||||
- `MAM_NO_SUBSCRIBER_DAEMON=1` 분기(`:2295-2296`)
|
||||
- stdin `/dev/null`, 로그 append(`>> "$log_file"`), 작업 공간 cwd, `--idle-timeout 0`
|
||||
- 바뀐 것은 분기 안의 세 줄뿐이다.
|
||||
- 호출하는 곳은 모두 `$(…)` 안이다. 그래서 `set -m` 은 그 서브셸 안에서만 잠깐 켜진다.
|
||||
- `update.sh:106`, `post_update.sh:41`, `install_phase.sh:64`
|
||||
- `create_session.sh:748`, `resume_session.sh:162`
|
||||
|
||||
### 1.2 이 Mac 에서 직접 확인 (`/bin/bash` 3.2, tty 없음, `set -euo pipefail`)
|
||||
|
||||
- 같은 모양의 함수를 스크래치 스크립트로 만들어 돌렸다.
|
||||
- 결과 1: `$(f)` 안에서 띄운 자식의 PGID 는 자기 PID 와 같다(53727 = 53727). 호출자의 PGID(53714)와 다르다.
|
||||
- 결과 2: 직접 호출한 경우도 마찬가지다.
|
||||
- 결과 3: 호출 뒤 `$-` 는 `ehuB` 로 그대로다. 곧 `m` 이 남지 않는다.
|
||||
- 결과 4: "no job control", "[1] PID", "Done" 같은 잡 메시지가 stdout 에도 stderr 에도 나오지 않았다. 그래서 `$(…)` 로 받는 PID 출력이 오염되지 않는다.
|
||||
- **고치기 전 코드에서 실패하는지도 확인했다.**
|
||||
- 방법: `git archive HEAD` 로 TMPDIR 에 사본을 풀었다. 그 사본의 `lib.sh` 만 1d63e88 판으로 바꿨다.
|
||||
- 그 사본에서 `-k test_every_restart` 를 실행했다.
|
||||
- 결과: **mode=0 의 3개(installed, killed, rolled-back)가 `left in the caller's group` 로 실패했다. mode=1 의 3개는 통과했다(3 failed, 3 passed).**
|
||||
- 즉 새 assertion(`tests/test_issue72_update.py:723-724`)이 R2 를 실제로 잡는다. 수정 뒤 6개가 모두 통과한다(§3).
|
||||
- 사본은 확인 뒤 지웠고, 남은 프로세스도 없다.
|
||||
- 세 경로와 두 모드: 정상 설치, 정지 시간 초과 뒤 복원, rollback 뒤 복원을 mode 0·1 로 돌리는 6 조합이 모두 같은 함수를 지나고, 모두 통과한다.
|
||||
- rollback 경로는 되돌린 트리의 `lib.sh` 를 쓴다. 테스트 상자에서는 그 트리가 이 버전이다.
|
||||
|
||||
### 1.3 python 경로와의 차이 (비차단, 참고)
|
||||
|
||||
- 두 경로가 분리하는 정도가 다르다.
|
||||
- python 경로(`daemon.py:95`): `start_new_session=True` 로 새 **세션**을 만든다. 그래서 제어 터미널에서도 떨어진다.
|
||||
- legacy 경로: 새 **프로세스 그룹**만 만든다. 세션과 제어 터미널은 호출자와 같다.
|
||||
- 결정 2 의 요구("부른 쪽 프로세스 그룹에서 분리")는 이것으로 충족된다.
|
||||
- 프로세스 그룹 신호(테스트 러너·timeout 래퍼의 killpg, 터미널의 SIGINT)는 이제 모니터에 닿지 않는다. 터미널 신호는 전경 그룹에만 가는데, 모니터는 배경 그룹이다.
|
||||
- 터미널이 닫힐 때의 SIGHUP 은 nohup 이 무시한다.
|
||||
- 배경 그룹이 tty 에 읽고 쓸 때 생기는 SIGTTIN/SIGTTOU 도 없다. stdin 은 `/dev/null` 이고 출력은 로그 파일로 간다.
|
||||
- `lib.sh:246-253`(shim 서버 기동)은 같은 문제에 `start_new_session=True` 를 쓰는 관례를 적어 두었다. legacy 경로만 `set -m` 을 쓴다.
|
||||
- 브리프가 "가장 작은 수정" 을 요구했고, legacy 경로는 opt-in 이므로 결함으로 보지 않는다.
|
||||
|
||||
## 2. 테스트의 `pkill` 제거 (`tests/test_issue72_update.py:184-194`, `:218-240`)
|
||||
|
||||
### 2.1 새 teardown 이 정리하는 것
|
||||
|
||||
- 패턴 kill 이 없어졌다. `tests/test_issue72_*.py` 전체에 `pkill`·`killall` 이 남아 있지 않다(grep 으로 확인).
|
||||
- 대신 다음 순서로 정리한다.
|
||||
1. 테스트가 `Box.helper`(`:132-135`, `start_new_session=True`)로 띄우고 `b.helpers` 에 기록한 프로세스의 그룹을 `killpg` 하고 `wait` 한다.
|
||||
2. 이 테스트의 작업 공간 `ws` 에 대해 `bash <ws>/…/reconcile.sh --subscribe` 형태로 정확히 맞는 모니터(`_exact_monitor_pids`)와 그 자손(`_descendants`, `pgrep -P` 재귀)만 SIGKILL 한다.
|
||||
- 남은 프로세스 확인: 명령줄에 `ws` 가 들어 있는 살아 있는 프로세스가 10초 안에 없어지지 않으면 이름을 적어 실패한다(`_processes_naming`, `:236-240`).
|
||||
- `ws` 는 basetemp 아래의 테스트별 경로다. 그래서 다른 작업 공간의 프로세스와 겹치지 않는다.
|
||||
|
||||
### 2.2 남은 프로세스 확인이 약해지지 않았는지
|
||||
|
||||
- 예전 `pkill -KILL -f <ws>` 는 `ws` 를 명령줄에 가진 것을 **조용히 죽였을** 뿐이다. 남은 프로세스를 확인하지는 않았다.
|
||||
- 새 방식은 정확히 고른 것만 죽인다. 나머지 가운데 `ws` 를 가진 프로세스가 남으면 **실패로 드러낸다**.
|
||||
- 그래서 확인은 더 강해졌다.
|
||||
- 대가가 하나 있다. 그런 프로세스가 생기면 정리되지 않고 남는다. 다만 이름이 실패 메시지에 나오므로 사람이 정리할 수 있다.
|
||||
- `ws` 를 명령줄에 갖지 않은 손자(예: stub 모니터의 `sleep 120`)는 두 방식 모두 패턴으로는 못 찾는다.
|
||||
- 새 방식은 모니터의 자손으로 찾아서 정리한다. 따라서 이 부분도 약해지지 않았다.
|
||||
- 이번 실행에서 teardown 실패는 없었다. 실행 뒤 `ps` 로 봐도 테스트가 남긴 프로세스가 없다(§3).
|
||||
|
||||
## 3. 실행한 테스트 (이 Mac: macOS, `/bin/bash` 3.2, `/usr/bin/python3` 3.9.6)
|
||||
|
||||
```
|
||||
cd <검증 경로>
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME \
|
||||
-u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider --basetemp="$TMPDIR/claude-j1d2c-bt" -q -rfs \
|
||||
tests/test_issue72_update.py tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||
tests/test_issue5_phase1_delegation.py
|
||||
```
|
||||
|
||||
- 결과: **132 passed, 0 failed, 0 skipped** (191 s)
|
||||
- `test_every_restart_starts_a_real_monitor_in_both_delegation_modes` 의 6 조합이 이 안에 들어 있다.
|
||||
- `test_issue19`·`test_issue5_phase1` 의 watchdog 시험(중복 방지, `MAM_NO_SUBSCRIBER_DAEMON`, PID 보고, legacy·python 동등성)이 모두 통과한다.
|
||||
- 고치기 전 대조 실행: TMPDIR 사본, `-k test_every_restart` → 3 failed(mode=0), 3 passed(mode=1). 자세한 것은 §1.2.
|
||||
- 테스트 뒤 남은 프로세스: 테스트가 띄운 `reconcile.sh`, stub monitor, mock herdr, 구독자는 없다. 남은 것은 원래 있던 셋이다.
|
||||
- 본 체크아웃 모니터 3714
|
||||
- 이번 루프 7552
|
||||
- 이번 잡을 디스패치할 때 띄운 job_subscriber 53086
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
### 차단
|
||||
|
||||
- 없음.
|
||||
|
||||
### 비차단
|
||||
|
||||
- **N8 (이월, R2 에도 해당).** rollback 뒤 재기동은 결정 2 에 따라 **되돌린 옛 트리의** `start_watchdog` 을 쓴다.
|
||||
- 그래서 실제로 이 PR 이전 버전으로 되돌리면, `MAM_DELEGATION_PY=0` 에서는 옛 `nohup … &` 그대로라 호출자 그룹에 남는다.
|
||||
- 이 PR 이후 버전 사이의 rollback 에서는 해결된다.
|
||||
- 결정 2 가 정한 방식("되돌린 트리의 함수")에서 생기는 한계라 결함으로 보지 않는다.
|
||||
- **1.3 의 세션 차이.** legacy 경로는 프로세스 그룹만 분리하고, python 경로는 세션까지 분리한다. 결정 요구는 충족한다.
|
||||
- N5·N6·N1·N3·N4 는 이번 범위 밖이라 그대로다.
|
||||
|
||||
## 5. 사용자 확인 필요
|
||||
|
||||
- N8: rollback 대상이 이 PR 이전 버전이고 `MAM_DELEGATION_PY=0` 인 경우를 받아들일지.
|
||||
- 받아들이지 않는다면 결정 2 의 "되돌린 트리의 start_watchdog" 대신 `install_phase.sh` 복사본 쪽에서 분리해 띄우도록 결정을 바꿔야 한다.
|
||||
- 이전 라운드에서 넘어온 N2, 그리고 결정 8 문구 보강 건은 그대로다.
|
||||
|
||||
## 6. 결론
|
||||
|
||||
- R2 는 정상 설치, 시간 초과 복원, rollback 복원의 세 경로와 두 모드 모두에서 해결됐다.
|
||||
- 고치기 전 코드에서 mode=0 이 실패하고, 고친 뒤 6개가 모두 통과하는 것을 이 Mac 에서 확인했다.
|
||||
- 분리 방식(`set -m`)은 중복 방지, opt-out, PID 보고, 로그를 바꾸지 않는다. bash 3.2 에서 잡 메시지 없이 동작한다.
|
||||
- 테스트의 패턴 kill 은 기록한 PID 와 정확히 맞는 모니터·자손 정리로 바뀌었다. 남은 프로세스 확인은 오히려 강해졌다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261029T045625Z-74296d",
|
||||
"base_sha": "b9c828f09214b4bed6651a223cbb941e7b432647",
|
||||
"head_sha": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"tree_sha": "ff705b61bf25fe40d0ff054c372a399eacc79730",
|
||||
"review_commit": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "2ab23f4e",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-29T06:08:06Z"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
# PR #79 (Issue #68) 리뷰 — head 3b81f99
|
||||
|
||||
리뷰어: planner-reviewer-claude-01 (claude) · job 2ab23f4e
|
||||
|
||||
## 0. 리뷰 경로 확인
|
||||
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1`
|
||||
- HEAD 는 `3b81f9973223d2a910369711c08adc772003639d`, tree 는 `ff705b61bf25fe40d0ff054c372a399eacc79730` 로 [리뷰 identity] 와 같다. 리뷰 전과 후 두 번 확인했다.
|
||||
- `git status --porcelain --ignored` 결과는 비어 있고, `.mam` 도 없다.
|
||||
- 리뷰 범위: base `b9c828f` 와 HEAD 사이의 커밋 5개, 9개 파일, +277/−22 줄.
|
||||
- 기준 문서(읽기만 했다): `tea issues 68 --comments` 의 결정 1–4 와 완료 조건, `tea pulls 79` 설명, `INSTRUCTION.md`.
|
||||
|
||||
## 1. 결정·완료 조건 대조표
|
||||
|
||||
| 조건 | 구현(파일:줄) | 테스트 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 결정 1: create 의 `--no-onboard` 유지, 기본은 온보딩 | `create_session.sh:67` `ONBOARD=1`, `:87-88` 그대로 | 기존 테스트 전체가 그대로 통과 | ✅ |
|
||||
| 결정 2: worktree_prepare 기본을 온보딩으로 | `worktree_prepare.sh:30` `ONBOARD=1` | `test_prepare_onboards_by_default_end_to_end`(온보딩 잡 completed, "onboarding completed" 출력) | ✅ |
|
||||
| 결정 2: `--no-onboard` 는 건너뛰고 create 에 그대로 넘김 | `:43`, `:232` `args+=(--no-onboard)` | `test_prepare_no_onboard_sends_none`, `test_prepare_forwards_onboarding_choice[opts2]` | ✅ |
|
||||
| 결정 2: 기존 `--onboard` 도 같은 동작 | `:42` | `forwards[opts1]`, `[opts3]`(둘 다 주면 마지막 것이 이김) | ✅ |
|
||||
| 결정 3: agy·codex·hermes·opencode 에만 경고, claude·grok 에는 없음 | `create_session.sh:208-211`, facts `lib_py/agents/__main__.py:75` | `test_facts_expose_post_discovered[6]`, `test_no_onboard_warning_dry_run[6]`, `..._keeps_exit_and_row[6]` | ✅ |
|
||||
| 결정 3: 경고 내용 "첫 대화 전까지 ID 미기록, 같은 폴더 같은 종류는 기다림(#63)" | `:210` 의 문구가 이 두 가지와 `MAM_ID_ORDER_WAIT_S` 를 말한다 | 경고에 `Issue #63` 과 에이전트 이름이 들어 있는지 확인 | ✅ |
|
||||
| 결정 3: 종료 코드·행이 바뀌지 않음 | 경고 출력만 하고 분기는 없다 | `keeps_exit_and_row`: rc 0, 행 필드(이름, status, role, server, workspace, cmd, cmd_full, cwd, id source·verified, 상태, control_plane)를 기대값과 비교 | ✅ |
|
||||
| 결정 3: 뒤에서 거부돼도 경고가 먼저 보임 | spawn·#63 검사(`:287` 부근)보다 앞에서 출력 | `test_no_onboard_warning_precedes_a_later_refusal` | ✅ |
|
||||
| 결정 4: 문서는 create SKILL.md 옵션 표와 worktree 절만 고침 | `SKILL.md:173`, `:205`, `:210`, `:213` 네 줄 | `test_issue42_skill_blocks`(블록 sha 와 근거 핀) | ✅ |
|
||||
| 완료: "기본은 온보딩 안 함" 을 전제로 한 worktree 테스트를 고침 | `test_issue21_acceptance.py:47`, `:207`, `test_issue21_phase2_loop_a.py:422` | 모두 통과 | ✅ |
|
||||
| 완료: macOS(bash 3.2, Python 3.9)에서 통과 | — | §3 | ✅ |
|
||||
|
||||
빠진 조건은 없다.
|
||||
|
||||
## 2. 꼼꼼히 본 것
|
||||
|
||||
### 2.1 경고 판정과 facts
|
||||
|
||||
- 이 Mac 에서 `lib_py.agents facts <agent>` 를 직접 실행했다.
|
||||
- agy, codex, hermes, opencode 는 `MAM_ID_POST_DISCOVERED=1` 이다.
|
||||
- claude, grok 은 `0` 이다.
|
||||
- 값은 어댑터의 `id_lifecycle`(`base.py:174-175` 의 `PRE_ASSIGNED`/`POST_DISCOVERED`)에서 나온다. 셸 쪽에는 에이전트 목록을 두지 않았다.
|
||||
- facts 를 쓰는 다른 곳에는 영향이 없다.
|
||||
- `lib.sh:2596`(eval 뒤 필요한 키만 읽음), `:2957`(eval), `:3156`(`sed -n 's/^KEY=//p'` 로 키별 추출)은 모두 키 단위로 읽는다. 그래서 줄이 하나 늘어도 문제가 없다.
|
||||
- facts 출력을 다루는 테스트(`test_a4_adapter_contract.py`, `test_issue23_24_startup_path.py`)도 이 Mac 에서 103 passed 였다.
|
||||
- facts 를 얻지 못하는 경우(파이썬 실패 등) `eval "$(… || true)"` 는 아무 값도 정하지 않는다. 그러면 `${MAM_ID_POST_DISCOVERED:-0}` 가 0 이 되어 경고 없이 지나간다. 동작이 바뀌지 않는 쪽으로 실패하므로 안전하다.
|
||||
- `MAM_DELEGATION_PY=0` 는 위임(delegation) 경로에만 쓰이는 스위치다. `lib_py.agents facts` 와는 관계가 없어 경고 판정에 영향이 없다.
|
||||
- `test_issue26_ints.py:111` 은 이 값을 "문자열 비교" 로 분류해 정수 기본값 검사 대상에서 뺐다. 코드도 실제로 문자열로 비교하므로(`= 1`) 맞는 분류다.
|
||||
|
||||
### 2.2 `--creator-agent` 를 부르는 모든 곳 (`git grep -n creator-agent`)
|
||||
|
||||
- 테스트
|
||||
- `test_issue21_acceptance.py:47`, `:207` 와 `test_issue21_phase2_loop_a.py:422` 에는 이제 `--no-onboard` 가 붙어 있다.
|
||||
- `test_issue21_phase2_loop_a.py:339`, `test_issue50_onboard.py:192`, `test_issue57_logs_follow_registry.py:276`, `test_issue50_dispatch_herdr.py:868` 는 원래 `--onboard` 를 명시하므로 동작이 같다.
|
||||
- `test_issue21_phase2_loop_a.py:355`(w6c)는 옵션이 없다. 하지만 서브모듈 초기화가 실패해 create 이전에 exit 1 로 끝나고, 행이 없음을 확인하는 테스트다. 그래서 온보딩까지 가지 않는다. 뒤에 다시 부르는 `_prepare(main, wt)` 에는 `--creator-agent` 가 없다.
|
||||
- 새 테스트 파일의 세 호출은 의도한 대로다.
|
||||
- 문서와 기록
|
||||
- `docs/design/worktree-creator-model.md:48-51` 의 예시에는 옵션이 없다. 그래서 이제 온보딩하는데, 예시가 보여 주려는 creator 흐름에도 그게 맞다. 이 문서에는 "기본은 온보딩 안 함" 이라는 문장이 없어 고칠 내용도 없다. 결정 4 의 "최소 변경" 과도 맞는다.
|
||||
- `VERSIONS.md:632` 는 과거 기록이고, `README*.md:51` 은 create 에 대한 설명이라 맞다.
|
||||
- 그 밖의 스크립트에서는 `worktree_prepare.sh` 를 부르는 곳이 없다.
|
||||
- 결론적으로 온보딩 대기(최대 `MAM_ONBOARD_WAIT_S`) 때문에 의도치 않게 느려지거나 44/45 로 끝날 호출은 남아 있지 않다.
|
||||
|
||||
### 2.3 SKILL.md 와 코드의 줄 단위 대조 (INSTRUCTION.md)
|
||||
|
||||
- `SKILL.md:173` 은 "`--no-onboard` 로 만든, ID 가 첫 턴 뒤에 생기는 에이전트(`id_lifecycle`)는 그때까지 ID 를 기록하지 않고, 같은 폴더의 같은 종류 create 를 막는다(#63). create 가 경고한다" 고 설명한다.
|
||||
- 코드(`create_session.sh:209-210`)의 조건과 문구가 이와 같다.
|
||||
- #63 쪽은 정확히는 "막는다" 가 아니라 "기다린 뒤 거부" 다. 하지만 표 한 줄 요약으로는 맞다.
|
||||
- `SKILL.md:205` 의 시놉시스 `[--no-onboard]` 는 `worktree_prepare.sh:9`, `:43` 과 같다.
|
||||
- `SKILL.md:210` 의 "onboards it … `--no-onboard` skips it, `--onboard` is the default and still accepted" 는 `:30`, `:42-43`, `:232` 와 같다.
|
||||
- `SKILL.md:213` 의 종료 코드 "44/45 are create's (…onboarding is the default…)" 는 스크립트 머리말 `:17-19` 와 같고, create 의 44/45 정의(`create_session.sh:22-23`)와도 같다.
|
||||
- 판단이 필요한 내용만 문서에 있고, 판정은 코드(adapter facts)에 있다. INSTRUCTION.md 를 따른 것이다.
|
||||
|
||||
### 2.4 `tests/data/skill_code_blocks.json`
|
||||
|
||||
- 근거 핀의 줄 번호 이동은 실제로 늘어난 줄 수와 맞는다.
|
||||
- `create_session.sh` 는 경고 4줄을 `:208` 에 넣었으므로 그 뒤 근거가 모두 +4 줄 밀렸다(360→364, 518-545→522-549 등).
|
||||
- `worktree_prepare.sh` 는 `--no-onboard` case 한 줄 때문에 +1 줄 밀렸다(250→251).
|
||||
- 내용이 바뀐 블록은 하나다. `SKILL.md:202-206` 의 worktree 시놉시스가 `[--onboard]` 에서 `[--no-onboard]` 로 바뀌었고, 그 블록의 sha 만 `38836d31bf38` 에서 `8bfabafb0328` 로 바뀌었다.
|
||||
- `test_issue42_skill_blocks.py` 가 sha 와 근거를 다시 계산해 통과했다(§3).
|
||||
|
||||
### 2.5 새 테스트가 결함을 잡는지
|
||||
|
||||
- base 대조 실행: `git archive HEAD` 를 TMPDIR 에 풀고, 코드 3개 파일(`__main__.py`, `create_session.sh`, `worktree_prepare.sh`)만 b9c828f 판으로 바꾼 뒤 `tests/test_issue68_onboard_default.py` 를 돌렸다.
|
||||
- 결과는 **20 failed, 5 passed** 로, 작성자의 주장과 같다.
|
||||
- 통과한 5건은 base 에서도 맞아야 하는 경우다. claude·grok 의 dry-run 과 행 시험 4건, 그리고 `--onboard` 전달 1건이다.
|
||||
- 사본은 확인한 뒤 지웠다.
|
||||
- Python 3.9 호환성: `str.removeprefix` 는 3.9 부터 있다. `list[str]` 주석은 `from __future__ import annotations` 덕분에 3.9 에서도 문제가 없다.
|
||||
|
||||
## 3. 실행한 테스트 (이 Mac: macOS, `/bin/bash` 3.2, `/usr/bin/python3` 3.9.6)
|
||||
|
||||
```
|
||||
cd <검증 경로>
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME \
|
||||
-u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider --basetemp="$TMPDIR/claude-j2ab-bt" -q -rfs \
|
||||
tests/test_issue68_onboard_default.py tests/test_issue21_acceptance.py tests/test_issue21_phase2_loop_a.py \
|
||||
tests/test_issue26_ints.py tests/test_issue42_skill_blocks.py tests/test_issue50_onboard.py \
|
||||
tests/test_issue57_logs_follow_registry.py tests/test_issue63_same_cwd_ids.py
|
||||
```
|
||||
|
||||
- 결과: **265 passed, 1 skipped, 0 failed** (724 s).
|
||||
- skip 1건은 `test_issue42_skill_blocks.py:266` 이다. untracked `.claude/skills` 가 worktree 에 없어서 생기는 환경 문제다.
|
||||
- facts 를 쓰는 다른 테스트 `tests/test_a4_adapter_contract.py`, `tests/test_issue23_24_startup_path.py` 는 **103 passed** (152 s).
|
||||
- base 대조 실행(§2.5)은 `test_issue68_onboard_default.py` 기준 20 failed, 5 passed.
|
||||
- `test_issue50_dispatch_herdr.py` 는 실제 herdr 서버를 띄우므로 브리프 제약에 따라 실행하지 않았다. 이 파일이 worktree_prepare 를 부르는 곳(`:868`)은 `--onboard` 를 명시하므로 이번 변경으로 동작이 바뀌지 않는다.
|
||||
- 테스트가 끝난 뒤 남은 프로세스: 테스트가 띄운 `reconcile.sh`, mock herdr, 구독자는 없다. 남아 있는 것은 원래 있던 셋뿐이다.
|
||||
- 본 체크아웃 모니터 3714
|
||||
- 이번 루프 88445
|
||||
- 이번 잡의 job_subscriber 88788 (13:56:28 시작)
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
### 차단
|
||||
|
||||
- 없음.
|
||||
|
||||
### 비차단
|
||||
|
||||
- **N1.** `tests/test_issue68_onboard_default.py:17` 의 `import yaml` 은 쓰이지 않는다. PR 설명에서도 남긴 것으로 밝혔다.
|
||||
- **N2.** `--no-onboard --submit-job "<프롬프트>"` 를 함께 주면, 제출한 잡이 첫 턴이 되는데도 경고가 나온다.
|
||||
- 이 조합에서는 원래 온보딩 프롬프트를 만들지 않는다(`create_session.sh:440`). 그래서 `--no-onboard` 가 사실상 아무 일도 하지 않는다.
|
||||
- 결정 3 의 조건("`--no-onboard` 를 주고 ID 후발견 에이전트이면")을 그대로 구현한 결과다. 그래서 결함은 아니고, 문구가 조금 지나치게 경고할 뿐이다.
|
||||
- **N3.** facts 를 얻지 못했는데 호출자 환경에 `MAM_ID_POST_DISCOVERED=1` 이 export 돼 있으면, claude 에도 경고가 나올 수 있다. 현실적인 경로가 없어 참고로만 적는다.
|
||||
|
||||
## 5. 사용자 확인 필요
|
||||
|
||||
- N2: `--submit-job` 과 함께 준 `--no-onboard` 에도 경고를 낼지. 지금은 결정 3 의 문언대로 경고를 낸다.
|
||||
|
||||
## 6. 결론
|
||||
|
||||
- 결정 1–4 와 완료 조건이 모두 구현됐고 테스트로 덮였다.
|
||||
- 새 테스트는 base 에서 20건이 실패하므로 결함을 실제로 잡는다.
|
||||
- 바뀐 테스트 파일과 create/worktree 관련 테스트가 이 Mac 에서 모두 통과한다.
|
||||
- SKILL.md 의 설명은 코드와 줄 단위로 맞는다.
|
||||
|
||||
[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-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-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "028e98cb",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# PR #75 후속 리뷰 — job 028e98cb
|
||||
|
||||
## 판정과 검증 대상
|
||||
|
||||
이번 범위의 차단 사항을 발견하지 않았다. R2의 PGID 분리와 테스트 teardown 수정이 요구 사항을 충족한다.
|
||||
|
||||
- base: 1d63e882bc1c46e9af8484647d5091b296d8fa3f
|
||||
- head: ec7f5c4e57286fb3c4238b8220f39094f4640391
|
||||
- tree: fbadb24de60ccdae684b23e2e0a41cdf23ab81eb
|
||||
- 검증 경로: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1
|
||||
- HEAD/tree 일치, clean status, `git diff --check 1d63e88..HEAD` 통과. 실제 diff는 lib.sh와 test_issue72_update.py 두 파일이다.
|
||||
- 새 브리프의 좁혀진 범위인 1d63e88..HEAD를 검토했다. 이전 비차단 권고에 해당하는 파일/기능을 추가 변경하지 않았음을 확인했다.
|
||||
- `tea issues 72 --comments`, `tea pulls 75`를 다시 읽었다. 기준 이슈 내용은 앞선 검토와 같고, 이번 수정은 결정 2 및 기존 중복 방지/opt-out/PID/로그 계약과 정합적이다.
|
||||
|
||||
## 확인한 것
|
||||
|
||||
| 항목 | 파일:줄 근거 | 확인 결과 |
|
||||
|---|---|---|
|
||||
| R2: 호출자 PGID에서 분리 | `.agents/skills/lib.sh:2303–2309` | 기존 monitor 옵션을 기록하고 spawn 때만 `set -m` 사용. 비동기 monitor가 별도 PGID에서 뜨며, 기존 옵션은 복원된다. macOS에 없는 setsid(1) 없이 동작. |
|
||||
| 기존 기동 계약 | `lib.sh:2289–2315` | 정확한 PID 검색과 중복 방지, opt-out 분기는 유지. cwd를 작업 공간으로 두고 원래 cwd로 복원, stdin /dev/null, 로그 append, --idle-timeout 0, nohup의 SIGHUP 처리와 PID stdout 계약 유지. |
|
||||
| 0/1 × 세 복원 경로 회귀 | `tests/test_issue72_update.py:699–725`, 특히 `:724` | 정상 설치·정지 timeout·rollback의 두 모드 모두 실제 새 monitor와 PGID 분리를 검증한다. decoy 미신호 및 기동 횟수 검증도 유지. 여섯 조합 모두 통과. |
|
||||
| pkill 제거·소유 프로세스 정리 | `test_issue72_update.py:184–193,220–240` | 기록된 helper 그룹을 정리하고 wait한다. monitor는 이 테스트의 정확한 절대 스크립트 명령행으로 고른 뒤, 미리 수집한 자손 PID와 함께 정리한다. 전역 패턴 kill은 없다. |
|
||||
| 잔여 프로세스 검출 유지 | `test_issue72_update.py:237–240` | ws를 명령행에 남긴 프로세스가 있으면 최대 10초 기다린 뒤 실패한다. 이 문자열 검색은 보고/검증용이며 kill 대상으로 사용되지 않는다. 원본 테스트를 외부 정리 플러그인 없이 실행해 통과. |
|
||||
|
||||
## R2의 전후 재현 결과
|
||||
|
||||
base 1d63e88의 부정 대조는 직전 d14ae099 리뷰에서 실제로 실행한 동일 외부 탐침 로그를 사용했다. 당시 mode=0의 정상·timeout·rollback 세 경우는 PGID가 호출자와 같아 실패했고, mode=1의 세 대조군은 통과했다. 이번에도 같은 탐침 코드를 변경 없이 지정된 새 snapshot에서 실행했다.
|
||||
|
||||
| 경로 | mode=0의 새 monitor PGID / caller PGID | mode=1의 새 monitor PGID / caller PGID |
|
||||
|---|---|---|
|
||||
| 정상 설치 | 93359 / 88049 — 분리 | 96481 / 88049 — 분리 |
|
||||
| 정지 timeout 복원 | 91992 / 88631 — 분리 | 94916 / 88631 — 분리 |
|
||||
| rollback 복원 | 93376 / 88631 — 분리 | 96166 / 88631 — 분리 |
|
||||
|
||||
- 기존 `probe_monitor_group.py`: **2 passed / 11.95s** (base에서는 1 failed, 1 passed).
|
||||
- 기존 `probe_monitor_restore_group.py`: **4 passed / 10.40s** (base에서는 2 failed, 2 passed).
|
||||
- 별도 직접 호출 탐침으로 monitor 옵션 초기 off→off 및 on→on, cwd 복원을 확인: **2 passed / 3.24s**. 새 monitor의 PGID 분리도 확인했다.
|
||||
- PGID 검증은 조회로 했으며 호출자의 프로세스 그룹에 신호를 보내지 않았다.
|
||||
|
||||
base 로그와 동일 탐침 코드, 이번 새 결과를 evidence/에 보존했다. base를 이번 허용 경로 밖에서 다시 실행한 것은 아니다.
|
||||
|
||||
## Mac에서 실행한 테스트
|
||||
|
||||
/bin/bash 3.2.57, Python 3.9.6에서 실행했다. 스냅샷에 .venv가 없어 브리프 앞부분에 명시된 제어 루트 .venv/bin/python을 사용했다.
|
||||
|
||||
작업 디렉터리는 위 snapshot이며, 주요 실행 명령은 다음과 같다.
|
||||
|
||||
```bash
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID \
|
||||
-u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION \
|
||||
-u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR \
|
||||
-u DELEGATE_JOB_LOGS_DIR -u AGENT_SESSIONS_YAML \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider \
|
||||
--basetemp=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review-028e98cb-pytest -ra \
|
||||
tests/test_issue72_update.py \
|
||||
tests/test_issue5_phase1_delegation.py::test_c07_watchdog_spawns_daemon_with_workdir_and_log \
|
||||
tests/test_issue5_phase1_delegation.py::test_c08_watchdog_second_call_reuses_running_daemon \
|
||||
tests/test_issue5_phase1_delegation.py::test_c09_start_watchdog_and_ensure_subscriber_daemon_agree \
|
||||
tests/test_issue5_phase1_delegation.py::test_daemon_missing_monitor_raises_with_the_legacy_path \
|
||||
tests/test_issue5_phase1_delegation.py::test_daemon_spawn_dedup_and_survives_parent_exit \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py::test_opt_out_still_reports_a_daemon_that_already_runs
|
||||
```
|
||||
|
||||
**59 passed / 133.12s / exit 0**. update 파일 50건, phase1 watchdog 8건, 기존 daemon의 opt-out 보고 1건이다.
|
||||
|
||||
같은 환경 제거·Python·no-cache 설정으로 실행한 추가 검증:
|
||||
|
||||
| 선택 | 결과 |
|
||||
|---|---|
|
||||
| `test_issue19_subscriber_daemon_lifecycle.py::test_sandbox_default_never_spawns_a_daemon` (0/1) | 2 passed / 0.53s |
|
||||
| `-p probe_monitor_group ... tests/test_issue72_update.py` | 2 passed / 11.95s |
|
||||
| `-p probe_monitor_restore_group ... tests/test_issue72_update.py` | 4 passed / 10.40s |
|
||||
| `-p probe_monitor_options ... tests/test_issue72_update.py` | 2 passed / 3.24s |
|
||||
|
||||
외부 탐침은 기존 Module의 Box fixture를 사용하고 항목만 외부 plugin으로 선택했다. source는 변경하지 않았다. 각 basetemp는 TMPDIR 아래 서로 다른 mam-review-028e98cb-{pytest,group,restore-group,options,optout}이었다. 이번에는 safe_cleanup 등의 외부 teardown 대체 없이 수정된 원본 cleanup을 실행했다.
|
||||
|
||||
- `/bin/bash -n .agents/skills/lib.sh`: 통과.
|
||||
- `ast.parse(test_issue72_update.py, feature_version=(3,9))`: 통과.
|
||||
- diff --check: 통과.
|
||||
- ShellCheck는 PATH에 없어 실행하지 못했다. bash -n을 ShellCheck 통과로 표기하지 않는다.
|
||||
- 이번 새 브리프가 정한 수정 파일·watchdog 범위로 실행했고 전체 스위트는 실행하지 않았다.
|
||||
|
||||
## 발견 사항 및 사용자 확인
|
||||
|
||||
차단/비차단 신규 지적 없음. R2와 pkill 제거 요구 모두 해결됐다. 새 정책 결정이나 재계획이 필요하지 않다.
|
||||
|
||||
## 자원 안전
|
||||
|
||||
- 검증은 지정된 snapshot에서만 했고 creator 작업 트리에서 실행·수정하지 않았다. installer/update는 TMPDIR 가짜 설치 및 로컬 source/file:// installer만 사용했다.
|
||||
- 실제 herdr 서버·설정·에이전트 홈 DB를 조작하지 않았고, 실제 .mam.env 값을 테스트에 출력하거나 export하지 않았다. 이슈/PR 댓글·상태 변경, push/merge 없음.
|
||||
- 다섯 basetemp의 종료 후 프로세스 점검 결과 `remaining_test_processes=[]`.
|
||||
- 원래 제어 루트 monitor PID 3714는 실행 전후 같은 명령으로 살아 있었으며 신호를 보내지 않았다.
|
||||
- 종료 시 HEAD/tree와 clean status가 그대로이며 diff --check도 다시 통과했다.
|
||||
- 승인된 잡 이벤트·리포트 외에 실제 control-plane 상태를 수정하지 않았다. 원본 로그와 외부 탐침 코드는 이 리포트 옆 evidence/에 보존했다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "65396b40",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Reviewer report — PR #70 / Issue #66
|
||||
|
||||
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`, head `bb496fde785133ad1dbec288aa7431766efb6eb5`, against base `0461a632eb667c7988fd33be92e9a4b36151f22a`. The snapshot matches expected tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d` and is clean. I read Issue #66 and PR #70; the implementation matches the requested scope.
|
||||
|
||||
## Findings
|
||||
|
||||
No blocking findings.
|
||||
|
||||
`HerdrSource` derives `self.name` from `sanitize_herdr_agent_name(target)` while resolving the adapter from the original name (`.agents/skills/lib_py/stall_watch.py:140-164`). Its agent `get`, `read`, and `prompt` calls use the sanitized name (`182-212`); the pane operations use pane IDs, so they do not need the session name. `StallWatch` separately derives the sanitized value for `watcher.wait_once` at line 229 and calls it at line 331, while notices/logs continue to use `args.target` (for example lines 391-398). `args.herdr_session` remains the herdr server selector passed as `session=`, separate from the sanitized agent name.
|
||||
|
||||
The transformation in `lib_py/agents/sanitize.py` follows the same lowercase, allowed-character replacement, leading-letter prefix, 32-character cap and SHA-1 suffix rules as the active `_sanitize_herdr_agent_name` in `lib.sh:293-318`. Agent kind detection uses the original target, which is necessary for long names whose sanitized suffix is a hash. The codex long-name test verifies this using a name whose original `-codex` suffix is absent from the sanitized form (`tests/test_issue66_stall_watch_sanitized_name.py:113-128`).
|
||||
|
||||
The mock's `strict_agent_names` check is opt-in (`tests/conftest.py:470-477`), so its default path retains the prior matching behavior. In strict mode, the tests verify that unconverted names receive `agent_not_found` for get/read/wait/prompt and sanitized names resolve (`test_issue66_stall_watch_sanitized_name.py:52-67`). Tests also verify transformed get/read/prompt/wait calls, idle and blocked notifications, preservation of the original name in log output, and unchanged plain-name behavior (`69-110`).
|
||||
|
||||
## Verification
|
||||
|
||||
- On the head snapshot, `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-65396b40-head-pytest tests/test_issue66_stall_watch_sanitized_name.py tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py` — **66 passed, 1 skipped** on macOS, Python 3.9.6 (357.58 s). The skip is the existing Linux-only `/proc` test.
|
||||
- For the base comparison, I checked out base into `/tmp/mam-review-65396b40-base`, copied in the new regression test and its strict-mock fixture change, and ran the same test file — **5 failed, 2 passed** (82.04 s). The failures are the expected unconverted-name calls for long and uppercase targets; the plain-name and strict-mock contract tests pass.
|
||||
- `git diff --check` — passed. No Bash files changed; the integration tests execute the watcher and mock herdr through the host Bash/Python environment.
|
||||
- The reported `test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` cleanup race is unrelated to this diff: the changed paths are `stall_watch.py`, the test-only mock in `tests/conftest.py`, and the new Issue #66 regression file; no delegation or process-cleanup implementation changed.
|
||||
- No real agent-home databases, actual `.mam` state, or real herdr servers were accessed.
|
||||
|
||||
[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-20261029T045625Z-74296d",
|
||||
"base_sha": "b9c828f09214b4bed6651a223cbb941e7b432647",
|
||||
"head_sha": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"tree_sha": "ff705b61bf25fe40d0ff054c372a399eacc79730",
|
||||
"review_commit": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "ee26d64c",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-29T06:08:06Z"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# PR #79 / Issue #68 리뷰 — PASS
|
||||
|
||||
- Job: ee26d64c / Reviewer: planner-reviewer-codex-01
|
||||
- 기준: `tea issues 68 --comments`의 “구현 전에 정한 것”(결정 1–4), `tea pulls 79`, `INSTRUCTION.md:5`.
|
||||
- Loop: `loop-20261029T045625Z-74296d`
|
||||
- Base: `b9c828f09214b4bed6651a223cbb941e7b432647`
|
||||
- Head: `3b81f9973223d2a910369711c08adc772003639d`
|
||||
- Tree: `ff705b61bf25fe40d0ff054c372a399eacc79730`
|
||||
- 검증 루트: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1`
|
||||
- 아래 파일:줄은 이 검증 루트 기준이다. 시작·종료 HEAD/tree 일치, `git status --short` 빈 출력, `git diff --check b9c828f..HEAD` 통과. creator 작업 트리에서 실행하거나 수정하지 않았다.
|
||||
|
||||
## 결정·완료 조건 대조
|
||||
|
||||
| 요구사항 | 코드·문서 근거 | 검증 | 빠진 조건 |
|
||||
|---|---|---|---|
|
||||
| 결정 1: create의 기본 온보딩 및 --no-onboard 유지 | `create_session.sh:67`, `:87`, `:88`, `:440` | 새 파일의 기본 dry-run 대조군·6종 실제 skip 행 검증, 기존 Issue #50 완료/44/45 테스트 | 없음 |
|
||||
| 결정 2: worktree creator 기본 온보딩, skip 전달, 기존 --onboard 수용 | `worktree_prepare.sh:30`, `:42`, `:43`, `:230`, `:232` | 새 테스트 `:195`, `:215`, `:233`: 옵션 전달 4조합 및 실제 mock 온보딩 완료/잡 미생성; 외부 탐침에서 반대 순서 옵션도 확인 | 없음 |
|
||||
| 결정 3: agy/codex/hermes/opencode에만 경고 1줄 | `lib_py/agents/__main__.py:75`, `create_session.sh:207`–`:211` | 새 테스트 `:50`, `:67`, `:95`: 6종 facts, dry-run/실제 create 및 기본 옵션 대조군 | 없음 |
|
||||
| 경고가 종료 코드·행을 바꾸지 않음 | 변경은 facts 출력과 stderr 조건문뿐; 행 기록 분기는 그대로 | 새 테스트 `:95`에서 rc=0, 이름·역할·status·cmd·cwd·ID 출처·검증 상태·control_plane·delegate_job_id 검증. base 대조에서도 이 검증 후 경고 단언에서만 실패 | 없음 |
|
||||
| 경고 이후의 거부도 기존 동작 유지 | `create_session.sh:209` 및 기존 #63 순서 게이트 | 새 테스트 `:157`: 미발견 agy 선행 세션, wait=0, rc=1 및 경고가 거부 설명보다 먼저 출력 | 없음 |
|
||||
| 결정 4: 문서 변경 최소화 | create `SKILL.md:173`, `:205`, `:210`, `:213` | 옵션 표 1줄·worktree 절 3줄만 변경. 각 설명을 위 코드와 대조 | 없음 |
|
||||
| 기존 implicit skip 호출 수정 및 Mac 통과 | `test_issue21_acceptance.py:47`, `:207`; `test_issue21_phase2_loop_a.py:422` | 변경 파일 5개와 관련 파일 3개 직접 실행. 아래 경로 이름 충돌 1건은 경로 보정 재실행으로 해소 | 없음 |
|
||||
|
||||
`create_session.sh`와 `worktree_prepare.sh`의 경로는 `.agents/skills/multi-agent-mux-create/scripts/` 아래다. 새 테스트 줄은 `tests/test_issue68_onboard_default.py` 기준이다.
|
||||
|
||||
## 추가 코드·호출부 확인
|
||||
|
||||
- 어댑터: `lib_py/agents/base.py:379`–`:382`의 기본값은 POST_DISCOVERED. claude의 `adapters/claude.py:188`–`:190`, grok의 `adapters/grok.py:18`–`:20`만 PRE_ASSIGNED로 재정의한다. 나머지 4종은 기본값을 사용한다. facts는 이 값을 0/1로 출력하므로 셸에 별도 에이전트 목록을 복제하지 않는다.
|
||||
- facts 소비자는 `lib.sh:2596`(readiness eval), `:2957`(send_keys_safe eval), `:3156`(dialog facts의 이름별 sed 선택), `create_session.sh:207`이다. 새 항목은 숫자 할당문이므로 기존 eval 계약을 유지하고, 이름별 파싱에도 섞이지 않는다. 기존 facts 계약·CLI 인용 테스트 통과.
|
||||
- `MAM_DELEGATION_PY=0`도 agents facts 호출을 건너뛰지 않는다. 외부 탐침에서 6종 경고 판정 및 worktree 기본 온보딩을 확인했다. facts 출력이 비거나 조회가 실패하면, 값이 미설정인 경우 `${MAM_ID_POST_DISCOVERED:-0}`에 의해 경고만 생략된다. rc=0/1의 빈 facts 탐침 모두 dry-run rc=0, nounset 오류 없음. Python 조회 불가 상황에 새 hard failure를 추가하지 않는 기존 best-effort 동작이다.
|
||||
- `git grep -n worktree_prepare.sh` 및 `git grep -n -- --creator-agent`로 추적 파일을 전수 확인했다. 실행 호출은 아래 표와 같다. 숨은 자동 호출 스크립트는 없다.
|
||||
|
||||
| 호출 위치 | 바뀐 기본값의 영향 |
|
||||
|---|---|
|
||||
| `test_issue21_acceptance.py:47,207`, `test_issue21_phase2_loop_a.py:422` | 온보딩과 무관한 기존 검증이므로 명시적 --no-onboard 추가가 맞음 |
|
||||
| `test_issue21_phase2_loop_a.py:339` | 이미 --onboard이며 실제 mock 완료를 검증 |
|
||||
| `test_issue21_phase2_loop_a.py:355` | submodule 초기화 거부가 creator 단계보다 앞서 발생; 온보딩 대기 없음 |
|
||||
| `test_issue50_onboard.py:192` | 기존 --onboard + stub의 44/45 전달 검증 유지 |
|
||||
| `test_issue50_dispatch_herdr.py:868` | 이미 --onboard를 명시한 실제 herdr 통합 테스트. 이번 제약에 따라 실행하지 않음 |
|
||||
| `test_issue57_logs_follow_registry.py:276` | 기존 --onboard 및 mock 완료. 이번 Mac 실행 통과 |
|
||||
| 새 테스트 `:202,219,237` | 전달·기본 온보딩·skip를 의도적으로 각각 검증 |
|
||||
| create `SKILL.md:205`, `docs/design/worktree-creator-model.md:48`–`:52` | 사용자 실행 예시는 이제 기본 온보딩을 수행. SKILL의 `:213`이 44/45 및 세션·worktree 보존을 명시 |
|
||||
| README/README.ko의 `:51`, VERSIONS의 역사 기록, `lib_py/worktree.py:3` | 설명/주석이며 추가 실행 호출 없음 |
|
||||
|
||||
- `tests/data/skill_code_blocks.json`: 변경된 evidence/passthrough 9곳을 base와 HEAD의 실제 줄 내용으로 비교해 모두 동일함을 확인했다. 변경된 비핀 필드는 worktree 사용 예시의 sha 1개(`38836d31bf38 → 8bfabafb0328`)뿐이며 --onboard → --no-onboard에 해당한다. inventory 테스트 통과.
|
||||
- 새 테스트는 단순 문자열 존재 검사에 그치지 않고 옵션 전달, 실제 mock 잡의 completed 상태, skip 시 잡 미생성, 행의 안정 필드, 후속 거부를 확인한다. 아래 base 대조 결과도 예상과 일치했다.
|
||||
|
||||
## Mac 실행 및 결과
|
||||
|
||||
환경: Darwin arm64, `bash`는 `/bin/bash` 3.2.57, `/usr/bin/python3` 및 사용한 제어 루트 venv Python은 3.9.6. 검증 트리에는 venv가 없어 브리프 상단에 지정된 제어 루트 인터프리터를 사용했다. 모든 pytest 실행 cwd는 검증 루트이며 공통 접두부는 다음과 같다.
|
||||
|
||||
```bash
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID \
|
||||
-u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION \
|
||||
-u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR \
|
||||
-u DELEGATE_JOB_LOGS_DIR -u AGENT_SESSIONS_YAML \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider --basetemp=<아래의 TMPDIR 경로> -q <선택 항목>
|
||||
```
|
||||
|
||||
TMPDIR 접두부는 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/`이다.
|
||||
|
||||
| 실행 | 선택 항목·추가 옵션 | 결과·증거 |
|
||||
|---|---|---|
|
||||
| 주 실행, basetemp `mam-review-ee26d64c-main` | `tests/test_issue68_onboard_default.py tests/test_issue21_acceptance.py tests/test_issue21_phase2_loop_a.py tests/test_issue26_ints.py tests/test_issue42_skill_blocks.py tests/test_issue50_onboard.py tests/test_uuid_target.py tests/test_issue21_worktree_prereqs.py` | 265 passed, 1 failed, 1 skipped / 819.55s. [main.log](evidence/main.log) |
|
||||
| 위 실패 사례 재실행, basetemp `mam-eval-ee26d64c-a4` | `tests/test_issue21_acceptance.py::test_a4_main_checkout_is_unchanged_by_the_whole_cycle` | 1 passed / 2.04s. [a4.log](evidence/a4.log) |
|
||||
| 계약/create 회귀, basetemp `mam-review-ee26d64c-contract` | `tests/test_a4_adapter_contract.py::test_agent_adapter_registry ::test_facts_bridge_eval_contract ::test_cli_bridge_subcommands_and_quote_safety`(각각 전체 node 경로 사용), `tests/test_tier2_component.py`, `tests/test_issue57_logs_follow_registry.py::test_worktree_prepare_keeps_the_logs_next_to_the_control_root_registry`; `-k 'create or facts_bridge or agent_adapter_registry or cli_bridge'` | 16 passed, 30 deselected / 184.63s. 마지막 logs 노드는 이 필터에서 제외되어 아래 별도 실행. [contract.log](evidence/contract.log) |
|
||||
| logs 별도 실행, basetemp `mam-eval-ee26d64c-logs` | `tests/test_issue57_logs_follow_registry.py::test_worktree_prepare_keeps_the_logs_next_to_the_control_root_registry` | 1 passed / 18.13s. [logs.log](evidence/logs.log) |
|
||||
| 외부 탐침, basetemp `mam-review-ee26d64c-probes` | `tests/test_issue68_onboard_default.py -s -p probe_issue68`; 추가 env `PYTHONPATH=/tmp/mam-review-ee26d64c-harness` | 12 passed / 41.84s. legacy 6종 경고+기본 온보딩 1건, 빈 facts 2건, 마지막 skip 옵션 1건, 기본 44/45 전달 2건. [probes.log](evidence/probes.log), [probe_issue68.py](evidence/probe_issue68.py) |
|
||||
| base 대조, basetemp `mam-eval-ee26d64c-base` | 같은 새 테스트 파일에 `-p base_issue68`; 같은 추가 PYTHONPATH | 의도한 20 failed, 5 passed / 77.25s. [base.log](evidence/base.log), [base_issue68.py](evidence/base_issue68.py) |
|
||||
|
||||
주 실행 실패는 `tests/test_issue21_acceptance.py:142`의 “worktree list 어디에도 mam-review 문자열이 없어야 한다”는 기존 단언 때문이다. 이 검토의 basetemp 이름 자체에 그 문자열이 들어갔다. 바로 앞의 HEAD·branch·status·branch tip·worktree list 원상복구 단언은 통과했다. 소스나 단언을 바꾸지 않고 basetemp 이름만 `mam-eval-ee26d64c-a4`로 바꾼 재실행이 통과했으므로 제품 회귀로 판정하지 않는다. 새 테스트 25건은 주 실행에서 모두 통과했다.
|
||||
|
||||
skip 1건은 `test_issue42_skill_blocks.py:266`: 검증 worktree에 추적되지 않는 `.claude/skills` 링크가 없는 경우다. 해당 링크의 합성 layout 테스트들은 실행됐다. 주 실행의 경로 보정까지 반영하면 제품 검증은 266건 통과·1건 skip이며, 추가 회귀/탐침 29건도 통과했다. 중복 확인한 A3 단독 실행은 별도 총계에 넣지 않았다.
|
||||
|
||||
base 대조는 다른 checkout에서 실행한 것이 아니다. HEAD의 새 테스트와 fixtures를 그대로 두고, `git show b9c828f:<path>`로 얻은 제품 파일 3개(create, worktree_prepare, agents CLI)만 임시 샌드박스에 넣었다. 직접 facts를 조회하는 6건은 base CLI를 임시 스크립트로 실행했고, 어댑터는 변경 없는 검증 트리 것을 사용했다. 실패 구성은 facts 6 + dry-run 경고 4 + 실제 행 검증 후 경고 4 + 후속 거부 경고 1 + 전달 3 + 기본 완료 1 + skip 1이다. 통과 5건은 claude/grok 경고 없음 각 2건과 기존 --onboard 전달 1건이다.
|
||||
|
||||
추가 정적 검사: 변경 셸 2개 `/bin/bash -n` 통과, 변경 Python 5개 `ast.parse(..., feature_version=(3,9))` 통과, diff 공백 검사 통과. ShellCheck는 설치되어 있지 않아 실행하지 않았다. 전체 스위트는 실행하지 않았다.
|
||||
|
||||
## 발견 사항
|
||||
|
||||
- **차단: 없음.**
|
||||
- **비차단(P3):** `tests/test_issue68_onboard_default.py:17`의 `import yaml`은 사용되지 않는다. 해당 1줄을 제거하면 된다. 동작성·데이터 유실 문제는 아니며 판정을 막지 않는다.
|
||||
- 위 A4 경로 문자열 단언의 민감성은 이번 diff에 도입되지 않았다. 이 리뷰에서는 경로 보정으로 검증을 완료했으며 PR 범위 확대를 요구하지 않는다.
|
||||
|
||||
## 격리·잔존 확인 및 사용자 확인 필요
|
||||
|
||||
모든 create/worktree 동작은 테스트의 mock herdr와 임시 작업 공간에서만 실행했다. 실제 에이전트 홈 DB를 열거나 실제 opencode 서비스·herdr 서버를 사용하지 않았다. 실제 .mam.env를 읽어 출력하거나 export하지 않았다. 테스트 종료 후 이번 실행의 임시 경로를 가진 프로세스는 0개이고, 실제 제어 루트 모니터는 시작·종료 모두 PID 3714다([process-audit.json](evidence/process-audit.json)). 패턴 kill, pkill, killall 및 실제 모니터에 대한 신호는 사용하지 않았다. 제품 소스·브랜치·이슈/PR 상태를 수정하거나 push/merge하지 않았다. 제어 평면 쓰기는 승인된 잡 이벤트와 이 리포트·증거뿐이다.
|
||||
|
||||
사용자 확인 필요: 없음. 결정 변경이나 재설계가 필요하지 않다.
|
||||
|
||||
[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-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "927ed539",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# PR #70 (`fix/issue-66`) Review Report — `agy` (`herdr:reviewer-agy-01`)
|
||||
|
||||
- **Job ID**: `927ed539`
|
||||
- **Issue / PR**: Issue #66 / PR #70 (`fix/issue-66`)
|
||||
- **Base Commit**: `0461a632eb667c7988fd33be92e9a4b36151f22a`
|
||||
- **Head Commit**: `bb496fde785133ad1dbec288aa7431766efb6eb5` (tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d`)
|
||||
- **Review Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
|
||||
- **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 #70 fixes Issue #66, where `lib_py.stall_watch` passed the unsanitized `--target` session name to `herdr agent get|read|prompt|wait`, causing `stall_watch` to immediately fail with `stall_watch:agent_not_found` (exit code 2) whenever the session name had been transformed by `_sanitize_herdr_agent_name` / `sanitize_herdr_agent_name` at session creation time (e.g. names > 32 characters such as worktree-scoped loop agents, uppercase characters, punctuation, or non-letter leading characters).
|
||||
|
||||
All 7 review checklist items, static/Python 3.9 compatibility checks, negative regression checks on `base` (`0461a632eb667c7988fd33be92e9a4b36151f22a`), and functional/regression test runs on `head` (`bb496fde785133ad1dbec288aa7431766efb6eb5`) pass cleanly with zero blocking or non-blocking defects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Checklist Verification
|
||||
|
||||
### 1. Does `stall_watch.py` use `sanitize_herdr_agent_name` for every `herdr` call (`agent get`, `agent read`, `agent prompt`, `agent wait`)?
|
||||
- **Verified (`PASS`)**:
|
||||
- In [`.agents/skills/lib_py/stall_watch.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L158-L212), `HerdrSource.__init__` computes `self.name = sanitize_herdr_agent_name(target)` while preserving `self.target = target`:
|
||||
- `HerdrSource.agent_get` (line 183): `self._herdr("agent", "get", self.name)`
|
||||
- `HerdrSource.input_empty` (line 206): `self._herdr("agent", "read", self.name)`
|
||||
- `HerdrSource.prompt` (line 211): `self._herdr("agent", "prompt", self.name, text)`
|
||||
- (`HerdrSource.agent_pid` at line 187 and `HerdrSource.blocked_text` at line 193 take `pane_id` (`wN:pM`) returned by `agent get`, not the agent name; `_herdr` at line 168 passes `self.session` to `--session`, which is the herdr server session `--herdr-session`.)
|
||||
- In [`StallWatch.__init__`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L225-L234) and [`StallWatch.run`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py#L331), `self.herdr_name = sanitize_herdr_agent_name(args.target)` is passed to `watcher.wait_once(self.herdr_name, until=until, timeout_ms=tmo, session=self.args.herdr_session)`.
|
||||
- Confirmed that `lib_py.agents.sanitize.sanitize_herdr_agent_name` and `lib.sh::_sanitize_herdr_agent_name` produce identical outputs across empty strings, lowercase/uppercase names, leading digits/dashes, >32-char names (`{s[:23]}-{sha1(s)[:8]}`), and special characters.
|
||||
|
||||
### 2. Are there any other places where `stall_watch.py` calls `herdr` with an unsanitized agent name?
|
||||
- **Verified (`PASS`)**:
|
||||
- Inspected all occurrences of `_herdr`, `herdr`, `wait_once`, `self.target`, and `self.args.target` across [`.agents/skills/lib_py/stall_watch.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/stall_watch.py) and [`.agents/skills/lib_py/loop/watcher.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/.agents/skills/lib_py/loop/watcher.py). No `herdr` invocation uses an unsanitized agent name.
|
||||
|
||||
### 3. Does `stall_watch.py` still keep the original `target` name for human-facing log lines and notifications?
|
||||
- **Verified (`PASS`)**:
|
||||
- All log lines in `StallWatch` (`stall_watch.py:373`, `397`, `405-406`) continue to format `self.args.target` (the original unsanitized session name):
|
||||
- `[stall-watch] idle detected for {self.args.target} (job {self.args.job_id}); sending nudge`
|
||||
- `[stall-watch] blocked detected for {self.args.target} (job {self.args.job_id}): {text}`
|
||||
- `[stall-watch] cannot judge input box for {self.args.target} (agent={self.kind}, job {self.args.job_id}); nudge withheld`
|
||||
- Progress notifications (`notify_progress`) continue to publish `stalled:idle`, `stalled:blocked ...`, `stalled:unverified_input ...`, and `stall_watch:...` keyed by `--job` (`self.args.job_id`).
|
||||
|
||||
### 4. Does `resolve_agent(agent, target)` still receive the original `target` name?
|
||||
- **Verified (`PASS`)**:
|
||||
- Both `HerdrSource.__init__` (`stall_watch.py:163`) and `StallWatch.__init__` (`stall_watch.py:230`) pass the original `target` (`args.target`) to `resolve_agent(...)`.
|
||||
- When `--agent` is omitted, `resolve_agent` infers the agent kind from the `-<agent>` suffix of `target` (e.g., `mam-wt-issue-66-very-long-creator-codex` -> `codex`). Passing the sanitized name (`mam-wt-issue-66-very-lo-823a0a24`) would have lost the `-codex` suffix due to 32-character truncation + SHA-1 suffixing. Keeping `resolve_agent` on the original `target` is verified by `test_the_kind_of_a_long_name_comes_from_its_original_suffix`.
|
||||
|
||||
### 5. In `tests/conftest.py`, is the `strict_agent_names` opt-in clean and backward-compatible with existing tests?
|
||||
- **Verified (`PASS`)**:
|
||||
- In [`tests/conftest.py`](file:///var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1/tests/conftest.py#L473-L476), `_match_agent(k, target)` checks `if state.get("strict_agent_names"): return k == target` and otherwise falls back to the existing substring match (`k == target or target in k or k in target`).
|
||||
- When `strict_agent_names` is enabled and an agent is not found, `cmd2 == "get"` (`tests/conftest.py:1058-1061`) and `cmd2 == "read"` (`tests/conftest.py:1153-1156`) emit `{"error": {"code": "agent_not_found", "message": "agent " + name + " not found"}}` on `stderr` with exit code 1, matching `wait`, `prompt`, and real `herdr`.
|
||||
- Existing tests that do not set `strict_agent_names` retain identical behavior.
|
||||
|
||||
### 6. Are the new tests in `tests/test_issue66_stall_watch_sanitized_name.py` genuine behavioral tests that would fail without the fix in `stall_watch.py`?
|
||||
- **Verified (`PASS`)**:
|
||||
- Executed `tests/test_issue66_stall_watch_sanitized_name.py` + updated `tests/conftest.py` against `base` (`0461a632eb667c7988fd33be92e9a4b36151f22a`, without the `stall_watch.py` fix):
|
||||
- **5 failed, 2 passed**: Every test case where `sanitize_herdr_agent_name(target) != target` (`creator-claude-fix-issue-99-long-branch`, `Creator-Claude-T`, and `mam-wt-issue-66-very-long-creator-codex`) failed on `base` (`stall_watch:agent_not_found` instead of blocked notice, or empty prompt list instead of idle nudge), while the 2 control tests (`test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one` and `test_an_idle_agent_is_nudged_through_the_sanitized_name[creator-claude-t]`) passed.
|
||||
- On `head` (`bb496fde785133ad1dbec288aa7431766efb6eb5`), all **7 passed**.
|
||||
|
||||
### 7. Is the one-off failure in `test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` unrelated to this diff?
|
||||
- **Verified (`PASS`)**:
|
||||
- Inspected `tests/test_issue17_item4_decisions.py:204-221` and `565-601`: that test stubs `*lib_py.stall_watch*` in its `$PY` wrapper to `exit 0` immediately and tests `finalize_unfinished` re-observing a live `lib_py.delegation.wrapper` process across a 3-second `broker_unavailable` window (`touch ready` + `time.sleep(2)` in `_terminate_unreaped`). It never imports or executes `lib_py.stall_watch` or `strict_agent_names`.
|
||||
- Both `test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` and `[timeout-2-10]` passed on `head` during our verification run.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verification Summary
|
||||
|
||||
| Check | Command / Scope | Result |
|
||||
|---|---|---|
|
||||
| Whitespace / patch hygiene | `git diff --check 0461a632eb667c7988fd33be92e9a4b36151f22a..bb496fde785133ad1dbec288aa7431766efb6eb5` | Clean (exit 0) |
|
||||
| macOS system Python 3.9 syntax & import | `/usr/bin/python3` (`3.9.6`) `ast.parse(..., feature_version=(3, 9))` + `import lib_py.stall_watch` | `PY39_AND_SANITIZE_EQUIV_OK` (exit 0) |
|
||||
| Bash vs Python sanitizer equivalence | `lib.sh::_sanitize_herdr_agent_name` vs `sanitize_herdr_agent_name` across 8 edge cases | Exact match (exit 0) |
|
||||
| Negative regression on `base` (`0461a63`) | `pytest tests/test_issue66_stall_watch_sanitized_name.py` on `0461a63` + strict mock | `5 failed, 2 passed` (reproduces `stall_watch:agent_not_found`) |
|
||||
| Behavioral & regression suites on `head` (`bb496fd`) | `pytest tests/test_issue66_stall_watch_sanitized_name.py tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py tests/test_issue17_item4_decisions.py -k "..."` | `91 passed, 1 skipped` in 399.91s (exit 0) |
|
||||
| Worktree cleanliness | `git status --short` in review worktree | Clean (no untracked or modified files) |
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "a967e100",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Code Review Report — PR #75 Follow-up Round (`1d63e88..ec7f5c4`, Issue #72)
|
||||
|
||||
- **Job ID**: `a967e100`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Loop ID**: `loop-20261027T201245Z-63c71b` (Round 1)
|
||||
- **Review Snapshot Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`
|
||||
- **Branch**: `fix/issue-72`
|
||||
- **Base Commit**: `1d63e882bc1c46e9af8484647d5091b296d8fa3f` (cumulative base: `0e1e2e87dea7fcaab69d6ac388121c35b02b7dab`)
|
||||
- **Head Commit**: `ec7f5c4e57286fb3c4238b8220f39094f4640391`
|
||||
- **Tree Hash**: `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb`
|
||||
|
||||
---
|
||||
|
||||
## 1. 확인한 것 (Files & Line Ranges Inspected in Review Snapshot)
|
||||
|
||||
- **`1d63e88..ec7f5c4` 변경분 집중 검증**:
|
||||
- `.agents/skills/lib.sh:2278-2317` (`_start_watchdog_legacy`):
|
||||
- `MAM_DELEGATION_PY=0` 경로에서 `case $- in *m*) had_m=1 ;; esac`, `set -m`으로 `nohup bash "$monitor_script" --subscribe --idle-timeout 0 </dev/null >> "$log_file" 2>&1 &`를 실행한 뒤 `[ "$had_m" = 1 ] || set +m`으로 호출자의 job-control 옵션을 복원(`lib.sh:2303-2309`).
|
||||
- macOS(`/bin/bash` 3.2, `setsid(1)` 없음)에서도 `set -m` 아래 백그라운드 작업이 자체 프로세스 그룹(`pgid == pid`)으로 기동되어 호출자의 프로세스 그룹에서 분리됨을 확인.
|
||||
- 기존 동작(`_monitor_pids` 중복 방지, `MAM_NO_SUBSCRIBER_DAEMON=1` opt-out, `echo "$pid"` 보고, `</dev/null`, `>> "$log_file" 2>&1`)이 그대로 보존됨을 확인.
|
||||
- `tests/test_issue72_update.py:184-195, 221-241, 697-726`:
|
||||
- `_descendants(pid)`(`184-186`) 및 읽기 전용 `_processes_naming(path)`(`189-193`) 추가.
|
||||
- `box` fixture teardown(`221-241`)에서 패턴 기반 `pkill -KILL -f <ws>`를 완전히 제거하고, 테스트가 직접 띄운 `b.helpers` 프로세스 그룹(`os.killpg`)과 `_exact_monitor_pids(ws / MONITOR_REL)` + 그 자식(`_descendants(pid)`)만 `SIGKILL`로 정리한 뒤 `_until(lambda: not _processes_naming(ws), timeout=10)`으로 잔존 프로세스가 없음을 검증.
|
||||
- `test_every_restart_starts_a_real_monitor_in_both_delegation_modes`(`697-726`): 6개 조합(`mode` `0`/`1` × `installed`/`killed`/`rolled-back`) 모두에서 `assert all(os.getpgid(int(p)) != os.getpgrp() for p in box.monitor_pids())`로 모니터의 PGID가 호출자의 PGID와 다름을 검증.
|
||||
- **누적 변경분(`0e1e2e8..ec7f5c4`) 정합성 확인**:
|
||||
- `.agents/skills/lib.sh:24-35, 2238-2317, 3299-3379`
|
||||
- `.agents/skills/lib_py/delegation/daemon.py:40-79`
|
||||
- `.agents/skills/lib_py/pane_lock.py:1-130`
|
||||
- `.agents/skills/lib_py/update.py:1-428`
|
||||
- `.agents/skills/multi-agent-mux-update/SKILL.md:1-30`
|
||||
- `.agents/skills/multi-agent-mux-update/scripts/{update.sh,install_phase.sh,post_update.sh}`
|
||||
- `.agents/skills/multi-agent-mux-{create,resume,stop,loop,orc-onboard,delegate-job}` 및 `deploy/{install.sh,install_mam.sh,remove.sh}`
|
||||
|
||||
---
|
||||
|
||||
## 2. 결정 1–10, U1–U4, Attach 댓글 및 완료 조건 대조표
|
||||
|
||||
| 항목 | 요구사항 요약 | 구현 위치 | 테스트 커버리지 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| 결정 1 | 활성 세션(레지스트리 `running` + 모든 실행 중 herdr 서버의 워크스페이스 내 라이브 에이전트)·루프·열린 잡(`pending`/`dispatched`/`running`, 타 제어 평면은 본 레지스트리 대상 잡만) 있으면 거부 및 해제 명령 안내 | `lib_py/update.py:150-267`, `update.sh:124-162` | `tests/test_issue72_update.py` (`test_every_refusal_condition_blocks_and_leaves_the_workspace_untouched` 외 9개) | 충족 |
|
||||
| 결정 2 | 모니터 데몬(`reconcile.sh --subscribe`) 자동 정지(`SIGTERM`→`SIGKILL`)·설치/타임아웃/롤백 후 호출자 프로세스 그룹에서 분리하여 재기동(`MAM_DELEGATION_PY=0/1` 모두 정확 매칭 및 별도 PGID); `resume` 시 모니터 보장 | `update.sh:100-116,165-212`, `post_update.sh:37-46`, `install_phase.sh:57-69`, `resume_session.sh:215-225,250,299`, `daemon.py:40-79`, `lib.sh:2238-2317` | `tests/test_issue72_update.py` (`test_every_restart_starts_a_real_monitor_in_both_delegation_modes` 외), `tests/test_issue72_resume_monitor.py` | 충족 |
|
||||
| 결정 3 | 설치 실패 시 `.agents`, `.mam_deploy`, 루트 문서, `.venv`, 설치기 관리 `.mam` 항목, 레지스트리 전체 되돌리기 | `update.sh:240-262`, `install_phase.sh:27-44,104-115` | `tests/test_issue72_update.py` (`test_a_failing_install_rolls_back_the_entire_workspace_and_restarts_the_monitor` 외) | 충족 |
|
||||
| 결정 4 | 업데이트 완료 후 새 코드로 레지스트리 변환(`lib_py.registry_format`), 레지스트리 없으면 생략, 실패 시 설치 유지 + 0 아닌 종료 | `post_update.sh:27-35` | `tests/test_issue72_update.py` (`test_a_conversion_failure_keeps_the_new_install_and_exits_nonzero` 외) | 충족 |
|
||||
| 결정 5 | 2단계 분리: 워크스페이스 밖 임시 복사본(`install_phase.sh`)을 `exec`(같은 PID 유지)한 뒤 새 트리의 `post_update.sh`를 별도 프로세스로 실행 | `update.sh:214-215,264-267`, `install_phase.sh:118-129` | `tests/test_issue72_update.py` (`test_the_clean_update_runs_both_phases_converts_the_registry_and_restarts_the_monitor`) | 충족 |
|
||||
| 결정 6 | `deploy/update.sh` 제거, `multi-agent-mux-update` 스킬로 일원화, `deploy/install.sh` 제자리 재실행 정리(없어진 파일 삭제·수정 파일 보존, `install_state` 제자리 갱신) | `deploy/install.sh:191-196,403-448,632-653`, `deploy/update.sh`(삭제) | `tests/test_issue72_installer.py`, `tests/test_deploy_layout.py`, `tests/test_issue8_update_state.py` | 충족 |
|
||||
| 결정 7 | 호출 에이전트 자신(`(caller_server, HERDR_PANE_ID)`)과 그 `running` 행·실행 중인 잡 1개는 차단 대상에서 제외 | `lib_py/update.py:192-219,229-230,244-250`, `lib_py/pane_lock.py:25-39` | `tests/test_issue72_update.py` (`test_the_calling_agent_its_row_and_its_running_job_are_excluded` 외 3개) | 충족 |
|
||||
| 결정 8 | 업데이트 입장 잠금(`.mam/update-admission.lock` 공유/배타 + `.mam/update-in-progress` 표식): `create`·`resume`·`run_loop`·`delegate submit`·`stop`·`orc-onboard` 차단, 자식 프로세스 fd 누수 방지 | `lib.sh:3312-3379`, `lib_py/pane_lock.py:42-115`, `lib_py/update.py:277-322` | `tests/test_issue72_admission.py` (21개 케이스) | 충족 |
|
||||
| 결정 9 | `update.sh [-y|--yes]`: TTY면 프롬프트, 비-TTY에서 `-y` 없으면 거부(`exit 1`) | `update.sh:30-60` | `tests/test_issue72_update.py` (`test_confirmation_prompt_and_non_tty_refusal`) | 충족 |
|
||||
| 결정 10 | 되돌리기 범위에서 작업 기록(`jobs`, `delegate_job_logs`, `state`, `worktrees`, `locks`) 보존 및 레지스트리 백업(`sqlite3.backup` + YAML/bak) 복원 | `update.sh:241-256`, `lib_py/update.py:342-366`, `install_phase.sh:37-44` | `tests/test_issue72_update.py` (`test_rollback_restores_every_item_the_installer_can_touch_and_leaves_work_records`) | 충족 |
|
||||
| U1 | attach 안내 헬퍼는 추가하지 않고 기존 행의 `attach_command`(`ATTACH_CMD`)를 그대로 사용 | `create_session.sh:654`, `multi-agent-mux-delegate-job:680-687` | `tests/test_issue72_attach_hints.py` | 충족 |
|
||||
| U2 | `.mam` 없는 트리에서 입장 잠금 헬퍼가 `.mam`이나 잠금 파일을 만들지 않고 통과 | `lib.sh:3315` | `tests/test_issue72_admission.py` (`test_a_tree_without_mam_stays_clean`) | 충족 |
|
||||
| U3 | `MAM_UPDATE_MONITOR_STOP_S`를 `mam_int_knob`으로 읽고 `test_issue26_ints.py` 표에 등록 | `update.sh:189`, `tests/test_issue26_ints.py:67` | `tests/test_issue26_ints.py` | 충족 |
|
||||
| U4 | 이전 업데이트가 남긴 `.mam/update-backup/` 상태별 처리(`backing-up`/`installed`/빈 상태 삭제, `installing`은 `--skip-registry`로 파일만 복원, 알 수 없는 상태 거부) | `update.sh:218-237`, `install_phase.sh:36` | `tests/test_issue72_update.py` (`test_a_leftover_backup_from_a_killed_update_is_handled_by_state`) | 충족 |
|
||||
| 댓글 #926, #930 | `multi-agent-mux-delegate-job` 및 `create_session.sh`의 attach 안내가 `--session <server>`를 포함한 `attach_command`를 출력 | `create_session.sh:654`, `multi-agent-mux-delegate-job:680-687` | `tests/test_issue72_attach_hints.py` (8개 케이스) | 충족 |
|
||||
| 첫 전환 (완료 조건 5) | 옛 `update.sh`가 있는 설치본에서 첫 전환 시 새 `install.sh`를 거쳐 `multi-agent-mux-update` 스킬과 레지스트리 변환까지 도달 | `deploy/install.sh:191-196,403-405`, `tests/test_issue72_update.py:909-966` | `tests/test_issue72_update.py` (`test_the_first_transition_from_the_old_updater_reaches_the_skill_and_conversion`) | 충족 |
|
||||
|
||||
빠진 완료 조건: **없음**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행한 테스트와 결과 (macOS 실측)
|
||||
|
||||
검증 경로(`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1`)에서 브리프의 격리 실행 명령으로 새·바뀐 테스트 파일과 watchdog 관련 테스트(`test_issue5_phase1_delegation.py`, `test_issue19_subscriber_daemon_lifecycle.py`)를 모두 실행했습니다.
|
||||
|
||||
### 명령 1 (`tests/test_issue72_update.py`, `tests/test_issue72_resume_monitor.py`, `tests/test_issue42_skill_blocks.py`)
|
||||
```bash
|
||||
BT="$(mktemp -d "${TMPDIR:-/tmp}/mam-rev-a967e100-1.XXXXXX")"
|
||||
trap 'rm -rf "$BT"' EXIT
|
||||
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1 && \
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp="$BT/pytest" \
|
||||
tests/test_issue72_update.py \
|
||||
tests/test_issue72_resume_monitor.py \
|
||||
tests/test_issue42_skill_blocks.py
|
||||
```
|
||||
- **결과**: `95 passed, 1 skipped in 379.90s (0:06:19)` (exit code `0`)
|
||||
|
||||
### 명령 2 (watchdog 관련 스위트 2종 + 나머지 변경·신규 테스트 7종)
|
||||
```bash
|
||||
BT="$(mktemp -d "${TMPDIR:-/tmp}/mam-rev-a967e100-2.XXXXXX")"
|
||||
trap 'rm -rf "$BT"' EXIT
|
||||
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1 && \
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp="$BT/pytest" \
|
||||
tests/test_issue5_phase1_delegation.py \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||
tests/test_deploy_layout.py \
|
||||
tests/test_issue26_ints.py \
|
||||
tests/test_issue8_update_state.py \
|
||||
tests/test_version_consistency.py \
|
||||
tests/test_issue72_attach_hints.py \
|
||||
tests/test_issue72_installer.py \
|
||||
tests/test_issue72_admission.py
|
||||
```
|
||||
- **결과**: `219 passed in 509.96s (0:08:29)` (exit code `0`)
|
||||
|
||||
### 명령 3 (`/bin/bash -n` 구문 검사 및 프로세스·워크트리 무결성 확인)
|
||||
- 변경된 모든 셸 스크립트(13개) `/bin/bash -n` 통과 (`exit 0`).
|
||||
- `git status --short`: 출력 없음 (검증 스냅샷 깨끗함).
|
||||
- `ps -ef`: 본 체크아웃의 기존 `reconcile.sh --subscribe` 데몬(PID `3714`)만 정상 유지 중이며, 테스트가 남긴 자식·더미 프로세스 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. 발견 사항 (차단 / 비차단)
|
||||
|
||||
- **차단 (Blocking)**: 없음.
|
||||
- **비차단 (Non-Blocking)**: 없음.
|
||||
|
||||
---
|
||||
|
||||
## 5. 사용자 확인 필요
|
||||
|
||||
- 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261029T045625Z-74296d",
|
||||
"base_sha": "b9c828f09214b4bed6651a223cbb941e7b432647",
|
||||
"head_sha": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"tree_sha": "ff705b61bf25fe40d0ff054c372a399eacc79730",
|
||||
"review_commit": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "ace63ec3",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-29T06:08:06Z"
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# Code Review Report — PR #79 (Issue #68: `worktree_prepare.sh` Default Creator Onboarding & `create --no-onboard` Warning)
|
||||
|
||||
- **Job ID**: `ace63ec3`
|
||||
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
|
||||
- **Loop ID**: `loop-20261029T045625Z-74296d` (Round 1)
|
||||
- **Review Snapshot Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1`
|
||||
- **Branch**: `fix/issue-68`
|
||||
- **Base Commit**: `b9c828f09214b4bed6651a223cbb941e7b432647`
|
||||
- **Head Commit**: `3b81f9973223d2a910369711c08adc772003639d` (5 commits)
|
||||
- **Tree Hash**: `ff705b61bf25fe40d0ff054c372a399eacc79730`
|
||||
|
||||
---
|
||||
|
||||
## 1. 확인한 것 (Files & Line Ranges Inspected in Review Snapshot)
|
||||
|
||||
- **기준 문서 대조 (`INSTRUCTION.md`, `tea issues 68 --comments`, `tea pulls 79`)**:
|
||||
- `INSTRUCTION.md:1-13` (스크립트·테스트 우선 원칙 및 `SKILL.md`와 코드의 줄 단위 일치 원칙 확인)
|
||||
- Issue #68 댓글("구현 전에 정한 것", 결정 1–4 및 완료 조건)과 PR #79 설명 대조
|
||||
- **어댑터 `id_lifecycle` 및 `facts` 브리지**:
|
||||
- `.agents/skills/lib_py/agents/__main__.py:4, 75` (`from lib_py.agents.base import POST_DISCOVERED`, `MAM_ID_POST_DISCOVERED={1 if adapter.id_lifecycle == POST_DISCOVERED else 0}`)
|
||||
- `.agents/skills/lib_py/agents/base.py:174-175, 379` (기본값 `POST_DISCOVERED`: `agy`, `codex`, `hermes`, `opencode`)
|
||||
- `.agents/skills/lib_py/agents/adapters/claude.py:188`, `grok.py:18` (`PRE_ASSIGNED`: `claude`, `grok`)
|
||||
- 다른 `lib_py.agents facts` 호출 지점 영향 확인:
|
||||
- `.agents/skills/lib.sh:2596-2598` (`wait_for_tui_ready` `eval "$_facts"`)
|
||||
- `.agents/skills/lib.sh:2957` (`send_keys_safe` `eval ...`)
|
||||
- `.agents/skills/lib.sh:3156-3170` (`_mam_load_dialog_facts` — `sed -n`으로 특정 키만 추출)
|
||||
- `tests/test_issue23_24_startup_path.py:480-500` (`MAM_STARTUP_DIALOGS=` 행만 필터링)
|
||||
- 모든 기존 호출자가 새 `MAM_ID_POST_DISCOVERED` 키 추가에 전혀 영향받지 않음을 확인.
|
||||
- **`create_session.sh` 및 `worktree_prepare.sh` 구현·문서 대조**:
|
||||
- `.agents/skills/multi-agent-mux-create/scripts/create_session.sh:67, 88, 207-211`:
|
||||
- `ONBOARD=1` 기본 유지, `--no-onboard` 유지.
|
||||
- `eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$AGENT" 2>/dev/null || true)"` 직후 `if [ "$ONBOARD" = 0 ] && [ "${MAM_ID_POST_DISCOVERED:-0}" = 1 ]; then`으로 stderr 경고 1줄 출력(`create_session.sh:209-211`).
|
||||
- `facts` 호출이 실패하거나 빈 값일 때도 `${MAM_ID_POST_DISCOVERED:-0}` 기본값 `0`으로 `set -u` 아래 안전하게 통과.
|
||||
- `spawn` 및 `--dry-run` 분기(`219`)보다 앞에 위치하여 `--dry-run`이나 이후 `#63` 대기 거부(`225`) 시에도 경고가 먼저 출력되며, 종료 코드와 레지스트리 행은 전혀 바꾸지 않음.
|
||||
- `.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh:6-19, 30, 42-43, 231-232`:
|
||||
- `ONBOARD=1`로 기본값 변경(`30`), `--onboard`는 `ONBOARD=1`로 계속 허용(`42`), `--no-onboard`는 `ONBOARD=0` 설정(`43`), `create_session.sh`에 `--onboard` 또는 `--no-onboard` 전달(`232`).
|
||||
- 상단 주석(`6-19`, `usage()`가 `sed -n '2,15p'`로 출력하는 범위 포함)이 코드와 정확히 일치.
|
||||
- `.agents/skills/multi-agent-mux-create/SKILL.md:173, 202-214`:
|
||||
- 옵션 표(`173`)와 `worktree_prepare.sh` 시놉시스(`205`), `--creator-agent` 설명(`210`), 종료 코드 설명(`213`)을 줄 단위로 코드와 대조하여 일치 확인.
|
||||
- **저장소 전체 `--creator-agent` 호출 전수 조사 (`git grep -- "--creator-agent"`)**:
|
||||
- `tests/test_issue21_acceptance.py:47, 207`: `--no-onboard` 명시 추가됨.
|
||||
- `tests/test_issue21_phase2_loop_a.py:339`: 기존 `--onboard` 명시.
|
||||
- `tests/test_issue21_phase2_loop_a.py:355`: 서브모듈 초기화 실패(단계 4) 테스트로 creator 단계(단계 5)에 도달하지 않음.
|
||||
- `tests/test_issue21_phase2_loop_a.py:422`: `--no-onboard` 명시 추가됨.
|
||||
- `tests/test_issue50_dispatch_herdr.py:868`, `tests/test_issue50_onboard.py:192`, `tests/test_issue57_logs_follow_registry.py:276`: 모두 기존 `--onboard` 명시.
|
||||
- `docs/design/worktree-creator-model.md:50`: 실제 워크트리 creator 생성 예시로 기본 온보딩 의도와 부합.
|
||||
- **핀 이동 및 정수 환경변수 분류**:
|
||||
- `tests/data/skill_code_blocks.json`: `create_session.sh` 208행 이후 참조들이 정확히 `+4`행, `worktree_prepare.sh` 43행 이후 참조가 `+1`행(`250`→`251`), `SKILL.md:202-206` 블록 해시가 `8bfabafb0328`로 내용 그대로 이동됨을 확인.
|
||||
- `tests/test_issue26_ints.py:111`: `NOT_ARITHMETIC`에 `"MAM_ID_POST_DISCOVERED"` 등록 확인.
|
||||
- **신규 테스트 (`tests/test_issue68_onboard_default.py:1-247`) 검증**:
|
||||
- 총 25개 케이스: `test_facts_expose_post_discovered`(6), `test_no_onboard_warning_dry_run`(6), `test_no_onboard_warning_keeps_exit_and_row`(6), `test_no_onboard_warning_precedes_a_later_refusal`(1), `test_prepare_forwards_onboarding_choice`(4), `test_prepare_onboards_by_default_end_to_end`(1), `test_prepare_no_onboard_sends_none`(1).
|
||||
- base `b9c828f` 기준 대조 결과: `claude`·`grok` 경고 없음 4건(`dry_run` 2 + `keeps_exit_and_row` 2)과 `--onboard` 전달 1건을 제외한 **20건이 base에서 정확히 실패**함을 코드로 확인(작성자 주장 일치).
|
||||
|
||||
---
|
||||
|
||||
## 2. 결정 1–4 및 완료 조건 대조표
|
||||
|
||||
| 항목 | 요구사항 요약 | 구현 위치 | 테스트 커버리지 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| 결정 1 | `create_session.sh`의 `--no-onboard` 유지, 기본값 온보딩(`ONBOARD=1`) 유지 | `create_session.sh:67, 88` | `test_no_onboard_warning_dry_run`, `test_no_onboard_warning_keeps_exit_and_row` | 충족 |
|
||||
| 결정 2 | `worktree_prepare.sh --creator-agent` 기본을 온보딩(`ONBOARD=1`)으로 변경, `--no-onboard` 추가 및 전달, 기존 `--onboard`도 동일 동작으로 수용 | `worktree_prepare.sh:30, 42-43, 232` | `test_prepare_forwards_onboarding_choice`, `test_prepare_onboards_by_default_end_to_end`, `test_prepare_no_onboard_sends_none` | 충족 |
|
||||
| 결정 3 | `create --no-onboard` 시 후발견 ID 에이전트(`agy`·`codex`·`hermes`·`opencode`)에만 `#63` 대기 가능성 경고 1줄 출력, `claude`·`grok`에는 미출력, 종료 코드·레지스트리 행 불변 | `lib_py/agents/__main__.py:75`, `create_session.sh:207-211` | `test_facts_expose_post_discovered`, `test_no_onboard_warning_dry_run`, `test_no_onboard_warning_keeps_exit_and_row`, `test_no_onboard_warning_precedes_a_later_refusal` | 충족 |
|
||||
| 결정 4 | `multi-agent-mux-create/SKILL.md` 옵션 표와 worktree 준비 절의 기본값 설명만 최소 수정 | `multi-agent-mux-create/SKILL.md:173, 205, 210, 213` | `tests/test_issue42_skill_blocks.py` | 충족 |
|
||||
| 완료 조건 3 | 기존 worktree 테스트 중 기본 미온보딩을 전제하던 호출 수정 및 macOS 통과 | `test_issue21_acceptance.py:47, 207`, `test_issue21_phase2_loop_a.py:422`, `test_issue26_ints.py:111` | 아래 macOS 실측 265 passed | 충족 |
|
||||
|
||||
빠진 완료 조건: **없음**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행한 테스트와 결과 (macOS 실측)
|
||||
|
||||
검증 경로(`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1`)에서 브리프의 격리 실행 명령으로 바뀐 테스트 파일과 create/worktree 관련 테스트를 모두 실행했습니다.
|
||||
|
||||
### 명령 1 (`test_issue68_onboard_default.py`, `test_issue26_ints.py`, `test_issue42_skill_blocks.py`, `test_issue50_onboard.py`)
|
||||
```bash
|
||||
BT="$(mktemp -d "${TMPDIR:-/tmp}/mam-rev-ace63ec3-1.XXXXXX")"
|
||||
trap 'rm -rf "$BT"' EXIT
|
||||
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1 && \
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp="$BT/pytest" \
|
||||
tests/test_issue68_onboard_default.py \
|
||||
tests/test_issue26_ints.py \
|
||||
tests/test_issue42_skill_blocks.py \
|
||||
tests/test_issue50_onboard.py
|
||||
```
|
||||
- **결과**: `177 passed, 1 skipped in 245.18s (0:04:05)` (exit code `0`)
|
||||
|
||||
### 명령 2 (`test_issue21_acceptance.py`, `test_issue21_phase2_loop_a.py`, `test_issue57_logs_follow_registry.py`, `test_issue63_same_cwd_ids.py`)
|
||||
```bash
|
||||
BT="$(mktemp -d "${TMPDIR:-/tmp}/mam-rev-ace63ec3-2.XXXXXX")"
|
||||
trap 'rm -rf "$BT"' EXIT
|
||||
cd /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1 && \
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest -p no:cacheprovider --basetemp="$BT/pytest" \
|
||||
tests/test_issue21_acceptance.py \
|
||||
tests/test_issue21_phase2_loop_a.py \
|
||||
tests/test_issue57_logs_follow_registry.py \
|
||||
tests/test_issue63_same_cwd_ids.py
|
||||
```
|
||||
- **결과**: `88 passed in 485.68s (0:08:05)` (exit code `0`)
|
||||
|
||||
### 명령 3 (`/bin/bash -n` 구문 검사 및 프로세스·워크트리 무결성 확인)
|
||||
- `create_session.sh`, `worktree_prepare.sh` `/bin/bash -n` 통과 (`exit 0`).
|
||||
- `git status --short`: 출력 없음 (검증 스냅샷 깨끗함).
|
||||
- `ps -ef`: 본 체크아웃의 기존 `reconcile.sh --subscribe` 데몬(PID `3714`)만 정상 유지 중이며, 테스트가 남긴 자식·더미 프로세스 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. 발견 사항 (차단 / 비차단)
|
||||
|
||||
- **차단 (Blocking)**: 없음.
|
||||
- **비차단 (Non-Blocking)**:
|
||||
- `[N-1]` `tests/test_issue68_onboard_default.py:18`: 쓰이지 않는 `import yaml` 1줄이 남아 있습니다(PR #79 본문에서도 인지됨, 동작에 영향 없음).
|
||||
|
||||
---
|
||||
|
||||
## 5. 사용자 확인 필요
|
||||
|
||||
- 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "7b602181",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# Review 7b602181 — PR #70 / Issue #66, Mac pass
|
||||
|
||||
PASS. Head `bb496fd` calls herdr with `sanitize_herdr_agent_name` of the session. `agent get`, `agent read`, `agent prompt`, and `agent wait` all use that name. The agent kind is still taken from `--agent` or from the original session suffix. Log lines keep the original session name. A name that is already legal is left as it is.
|
||||
|
||||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1` (`loop-20261025T023502Z-d6f876`). Head `bb496fde785133ad1dbec288aa7431766efb6eb5`, tree `15a72c9a1933c64018a2f2d9aa9707348905bb4d`. Base `0461a632eb667c7988fd33be92e9a4b36151f22a` (main, v4.10.0) is an ancestor and is the control-plane HEAD. One commit, 3 files, +150/−5. No uncommitted changes. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree `mam-wt-pr70` was not run.
|
||||
|
||||
The commit is `bb496fd` `fix(stall_watch): call herdr with the sanitized agent name`.
|
||||
|
||||
Host tools: control-plane venv Python 3.9.6, `/bin/bash` 3.2.57(1)-release. The real agent databases were not opened.
|
||||
|
||||
## Herdr name and kind
|
||||
|
||||
`HerdrSource` keeps the original string on `self.target` and stores `self.name = sanitize_herdr_agent_name(target)` (`stall_watch.py:158-165`). `agent_get`, `agent read`, and `agent prompt` pass `self.name` (`182-184`, `206`, `210-211`). `StallWatch.herdr_name` is the same function of `args.target` (`229`). `watcher.wait_once` receives that name, and `args.herdr_session` stays the server (`331`).
|
||||
|
||||
`build_argv` puts a non-default server on `herdr --session` and the target on `agent wait <target>` (`loop/watcher.py:131-139`). `HerdrSource._herdr` does the same split (`stall_watch.py:167-168`). The server argument is not run through the agent-name sanitizer.
|
||||
|
||||
`resolve_agent` still reads `--agent`, then its first hyphen piece, then `agent_of_row` on the original session name (`stall_watch.py:140-146`, `registry.py:30-49`, `base.py:326-330`). Measured on this snapshot: `resolve_agent("", "mam-wt-issue-66-very-long-creator-codex")` is `codex`. The sanitized form `mam-wt-issue-66-very-lo-d55dfa83` does not end in `-codex`, and `resolve_agent` of that string with an empty `--agent` is `None`. `resolve_agent("claude", <39-character name>)` is `claude`. `resolve_agent("claude-code", ...)` is `claude`. The long-name test passes `--agent ""` and still nudges, which is the codex input-box path (`test_issue66_stall_watch_sanitized_name.py:111-127`).
|
||||
|
||||
No remaining herdr agent-name argument in `stall_watch.py` uses `self.target` or `args.target`. Those strings appear in the log lines (`373`, `397`, `406`). Pane calls use the pane id (`186-193`).
|
||||
|
||||
`_notify` publishes `progress` with the detail it is given (`237-244`). Idle publishes `stalled:idle` (`408`). Blocked publishes `stalled:blocked` plus the dialog line (`374`). Those details contain the stall reason. The original session name is in the matching log line (`agent <original> idle for`, `agent <original> is blocked`). The tests lock both (`79-85`, `101-106`).
|
||||
|
||||
## Sanitizer
|
||||
|
||||
`sanitize_herdr_agent_name` (`lib_py/agents/sanitize.py:4-30`) lowercases, replaces characters outside `[a-z0-9_-]` with `-`, prefixes `x-` when the result does not start with a letter, and when the result is longer than 32 characters keeps 23 characters, a hyphen, and 8 hex digits of SHA-1.
|
||||
|
||||
The function `lib.sh` runs is the definition at `lib.sh:293-316` (an earlier copy at `36-61` is replaced when the file is sourced; every call site is below 316). It was executed on its own, without sourcing `lib.sh`. On this Mac, with `shasum` present, it returned the same strings as the Python function:
|
||||
|
||||
| input | result |
|
||||
| --- | --- |
|
||||
| `creator-claude-fix-issue-99-long-branch` (39) | `creator-claude-fix-issu-e6fc18ae` |
|
||||
| `Creator-Claude-T` | `creator-claude-t` |
|
||||
| `creator-claude-t` | `creator-claude-t` |
|
||||
| `mam-wt-issue-66-very-long-creator-codex` | `mam-wt-issue-66-very-lo-d55dfa83` |
|
||||
| empty | `agent` |
|
||||
| `1abc` | `x-1abc` |
|
||||
| `A B` | `a-b` |
|
||||
| 33 `x` | `xxxxxxxxxxxxxxxxxxxxxxx-60bbb3c8` |
|
||||
| `planner-agy` | `planner-agy` |
|
||||
|
||||
The earlier shell copy produced the same three long, codex, and `1abc` results. Python 3.9.6 imports `stall_watch`. The new code adds no `match` statement and no evaluated `X | Y` annotation. The module already has `from __future__ import annotations`.
|
||||
|
||||
## Mock
|
||||
|
||||
`strict_agent_names` is off unless a test sets it. `_match_agent` then keeps the previous comparison (`conftest.py:470-476`). With the flag set, the mock answers only an exact registered name. `agent get` and `agent read` then print `agent_not_found` JSON (`1057-1062`, `1152-1158`). `agent wait` and `agent prompt` already did (`1089-1096`, `1187-1194`). Existing tests that leave the flag unset keep the old not-found text on get and read.
|
||||
|
||||
## Tests
|
||||
|
||||
`test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one` registers the sanitized name and checks that get, read, wait, and prompt reject the original name with `agent_not_found` and accept the sanitized name (`51-65`).
|
||||
|
||||
`test_an_idle_agent_is_nudged_through_the_sanitized_name` covers the 39-character name, `Creator-Claude-T`, and `creator-claude-t`. Every get, read, prompt, and wait uses the sanitized name, one nudge is sent, the progress detail is `stalled:idle`, and the log contains `agent <original> idle for` (`68-85`).
|
||||
|
||||
`test_a_blocked_agent_is_reported_with_the_original_name` uses the sanitized name on the herdr calls, sends no prompt, publishes `stalled:blocked` plus the dialog line, and logs `agent <original> is blocked` (`90-106`).
|
||||
|
||||
`test_the_kind_of_a_long_name_comes_from_its_original_suffix` passes an empty `--agent`. The sanitized name does not end in `-codex`. The watcher still nudges and every herdr call uses the sanitized name (`111-127`).
|
||||
|
||||
## Results
|
||||
|
||||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam7b6-7b602181-pytest` (head) and `/tmp/mam7b6-7b602181-base-pytest` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_*`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, the four `XDG_*` homes, and `AGENT_PYTHON_BIN` were dropped.
|
||||
|
||||
Head, cwd the snapshot: `tests/test_issue66_stall_watch_sanitized_name.py`, `test_issue30_stall_watch.py`, `test_issue30_stall_watch_stop.py`. **66 passed, 1 skipped** in 356.72 s. Exit 0. The log closed at 2026-10-25 12:31:50. The skip is `test_s6_the_heartbeat_timer_is_not_a_background_child`, marked for Linux because it reads `/proc` (`test_issue30_stall_watch.py:39-40`, `340-341`). That test is outside this delta. The seven Issue #66 tests passed inside the 66.
|
||||
|
||||
Base, this turn: a throwaway `git clone --shared` checked out at `0461a632eb667c7988fd33be92e9a4b36151f22a`, with only `tests/test_issue66_stall_watch_sanitized_name.py` copied from head. Base `stall_watch.py` has no `sanitize_herdr` use. **6 failed, 1 passed** in 37.05 s. Exit 1. The pass is `test_an_idle_agent_is_nudged_through_the_sanitized_name[creator-claude-t]`, the name that needs no conversion. The six failures are the long name, the uppercase name, and the long codex name. On that tree get, read, and wait are invoked with `creator-claude-fix-issue-99-long-branch` and `Creator-Claude-T`. The snapshot was not checked out. Snapshot porcelain stayed empty.
|
||||
|
||||
## Issue #17 timing failure
|
||||
|
||||
`tests/test_issue17_item4_decisions.py::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]` is outside this diff. The test sends `SIGTERM` to a delegate wrapper during finalize and expects exit `-15` within 4.5 seconds, an empty call log, and an unchanged job record (`583-601`). The diff names only `stall_watch.py`, `tests/conftest.py`, and the new Issue #66 test. It does not touch the wrapper, `finalize_unfinished`, or that test. A miss on that timing assertion is a cleanup race in the wrapper test, separate from the herdr agent name.
|
||||
|
||||
## Blocking defects
|
||||
|
||||
None.
|
||||
|
||||
## Non-blocking
|
||||
|
||||
- `lib.sh` contains two copies of `_sanitize_herdr_agent_name` (`36-61` and `293-316`). Sourcing the file uses the second. On this host, with `shasum` present, both copies matched Python for the names checked above. The first copy's missing-hasher path truncates to 32 characters with no hash. This commit does not change `lib.sh`.
|
||||
- Progress details stay `stalled:idle` and `stalled:blocked <dialog>`. They carry the stall reason. The original session name is on the log line beside that notice.
|
||||
|
||||
## Host check
|
||||
|
||||
No process has a first command token of `opencode` or a path ending in `/opencode`. `lsof` on `~/.local/share/opencode/opencode.db` exited 1. That database was not opened. Its mtime is 1792641027 and its size is 36712448. `opencode.log` is absent. `~/.config/opencode/service.json` mtime is 1792246838 and its size is 64. `~/.local/state/opencode/service.json` is absent. The herdr config mtime is 1784456394. `~/.local/state/mam` mtime is 1792846843. `~/.local/state/mam/id-order` is a directory, mtime 1792846886. These stats match the check taken before the suite.
|
||||
|
||||
Five herdr processes were running immediately before the suite. After the suite log closed at 2026-10-25 12:31:50, the 12:31:59 check still found five. One pid from the before-set was gone. The replacement is `/opt/homebrew/bin/herdr` with `--session multi-agent-mux`, elapsed time about 15 seconds at 12:31:59, so it started near 12:31:44, during the last seconds of the suite. No herdr command line contained the review basetemp, the snapshot path, or `d6f876`. This review did not start or stop a herdr server.
|
||||
|
||||
After this report file was written, the same check was repeated at 2026-10-25 12:33:25. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty at `0461a632eb667c7988fd33be92e9a4b36151f22a`. No process has a first command token of `opencode` or a path ending in `/opencode`, and `lsof` still reports no database holder. The database mtime is still 1792641027 and its size is still 36712448. `opencode.log` is still absent. The config service file mtime is still 1792246838. The state service file is still absent. The herdr config mtime is still 1784456394. `~/.local/state/mam` mtime is still 1792846843, and `id-order` is still the same directory (mtime 1792846886). Five herdr processes were present. The pid set differs from the 12:31:59 set by one homebrew herdr process whose arguments include `--session multi-agent-mux`; its elapsed time was about 41 seconds, so that process had turned over again. No herdr command line contained the review basetemp, the snapshot path, or `d6f876`. This review did not start or stop it.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261029T045625Z-74296d",
|
||||
"base_sha": "b9c828f09214b4bed6651a223cbb941e7b432647",
|
||||
"head_sha": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"tree_sha": "ff705b61bf25fe40d0ff054c372a399eacc79730",
|
||||
"review_commit": "3b81f9973223d2a910369711c08adc772003639d",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "8900db9e",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-29T06:08:06Z"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# PR #79 (Issue #68) 리뷰
|
||||
|
||||
검증 경로는 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261029T045625Z-74296d/1` 뿐이다. HEAD `3b81f9973223d2a910369711c08adc772003639d`, tree `ff705b61bf25fe40d0ff054c372a399eacc79730`, base `b9c828f09214b4bed6651a223cbb941e7b432647` 는 조상이다. 작업 트리는 깨끗하고 `git diff --check` 도 깨끗하다. 변경은 9파일, +277/−22 이다. 스냅샷에는 `.venv` 가 없다. 테스트는 제어 평면 Python 3.9.6 과 `/bin/bash` 3.2.57 로 돌렸다.
|
||||
|
||||
## 확인한 것
|
||||
|
||||
결정 1–4 는 코드와 같다.
|
||||
|
||||
| 완료 조건 | 구현 | 테스트 |
|
||||
|---|---|---|
|
||||
| create 의 `--no-onboard` 유지, 기본은 onboarding | `create_session.sh:51` 도움말, `:67` `ONBOARD=1` | dry-run 기본 경로는 경고 0줄 (`test_issue68_onboard_default.py:84-91`) |
|
||||
| `worktree_prepare.sh --creator-agent` 는 기본 onboarding. `--no-onboard` 는 create 로 넘기고, `--onboard` 는 같은 뜻 | `:30` `ONBOARD=1`, `:42-43` 마지막 플래그가 이김, `:232` 둘 중 하나만 전달. 44/45 는 `:247-248`, `exit "$rc"` 는 `:251` | 전달 4건, 기본 e2e 1건, `--no-onboard` 면 잡 없음 1건 |
|
||||
| 경고는 agy·codex·hermes·opencode 만. claude·grok 는 없다. 종료 코드와 행은 그대로 | `:208-210`. 판정은 facts 의 `MAM_ID_POST_DISCOVERED` | dry-run 6, 실제 행 6, 이후 거절 1 |
|
||||
| 문서 변경은 create SKILL.md 옵션 표와 worktree 절 | `SKILL.md:173`, `:205`, `:210`, `:213` | `tests/test_issue42_skill_blocks.py` 핀이 현재 본문과 일치 (아래 1건 스킵) |
|
||||
|
||||
경고 판정은 셸의 에이전트 목록이 아니다. `__main__.py:75` 가 `adapter.id_lifecycle == POST_DISCOVERED` 이면 `MAM_ID_POST_DISCOVERED=1`, 아니면 `0` 을 찍는다. 기본값은 `base.py:379-382` 의 `post_discovered` 이고, `claude.py:188-190` 과 `grok.py:18-20` 만 `pre_assigned` 다. 기대표(`test_issue68_onboard_default.py:30-37`)와 같다.
|
||||
|
||||
facts 를 통째로 eval 하는 `lib.sh:2957` 은 토큰 변수만 읽고, `_mam_load_dialog_facts` (`lib.sh:3157`) 는 `MAM_STARTUP_DIALOGS` 줄만 꺼낸다. 추가 대입 한 줄은 그 두 경로의 결과를 바꾸지 않는다. `MAM_DELEGATION_PY=0` 은 `lib.sh:2323` 근처의 레거시 헬퍼 스위치이고, create 의 facts 호출(`create_session.sh:207`)은 그 스위치 밖에 있다. facts 가 실패하면 `|| true` 로 변수가 비고, `${MAM_ID_POST_DISCOVERED:-0}` 이라 경고는 나오지 않는다.
|
||||
|
||||
경고는 spawn 전이다. `--dry-run` 종료는 `create_session.sh:286` 이고, `#63` 대기는 `:291` 이다. 그래서 dry-run 에도 경고가 나오고, 이후 거절보다 경고가 먼저다.
|
||||
|
||||
`--creator-agent` 호출:
|
||||
|
||||
- `tests/test_issue21_acceptance.py:47`, `:207` 과 `tests/test_issue21_phase2_loop_a.py:422` 는 `--no-onboard` 를 넘긴다. `:339` 는 원래 `--onboard` 다.
|
||||
- `test_w6c` (`test_issue21_phase2_loop_a.py:355`) 는 플래그가 없다. 서브모듈 초기화가 `:356` 에서 실패하고 `:360` 에서 행이 비어, creator 까지 가지 않는다.
|
||||
- `tests/test_issue50_onboard.py:192` 와 `tests/test_issue57_logs_follow_registry.py:276` 은 `--onboard` 다.
|
||||
- `tests/test_issue50_dispatch_herdr.py:868` 은 `--onboard` 이고 실제 herdr 를 띄운다. 이번 리뷰에서는 실행하지 않았다.
|
||||
- 다른 스크립트가 `worktree_prepare.sh` 를 호출하지는 않는다. `docs/design/worktree-creator-model.md:50` 의 claude 예시는 플래그가 없어서 이제 기본 onboarding 이다. `README.md:51` 은 `--onboard` 와 준비 스크립트만 언급한다. `VERSIONS.md` 는 당시 기록이다.
|
||||
|
||||
`tests/data/skill_code_blocks.json` 의 create 핀은 경고 4줄이 들어간 뒤쪽 범위가 +4 다 (`360`→`364`, `518-545`→`522-549`, `259-274`→`263-278`, `490-608`→`494-612`, exit 4 핀 `550`→`554`). worktree 의 `exit "$rc"` 핀은 옵션 한 줄 때문에 `250`→`251` 이고, `:24` 는 그대로다. sha `38836d31bf38`→`8bfabafb0328` 은 줄 이동이 아니라 SKILL 예시가 `--no-onboard` 로 바뀐 것이다 (`skill_code_blocks.json:90-102`, `SKILL.md:202-206`). 핀 테스트는 그 본문과 맞춰 통과했다.
|
||||
|
||||
새 테스트 25건을 base 에 올려 놓지 않고 단정만 세면, facts 6건, dry-run 경고 4건, 행 검사의 경고 단계 4건(`:150-152`. 행 단정은 그 앞에 있어 base 는 경고에서 실패), 거절 1건, 전달 4건 중 명시적 `--onboard` 를 뺀 3건, 기본 e2e 1건, `--no-onboard` 준비 1건이 실패한다. 20실패·5통과로 작성자 주장과 같다. 통과하는 5건은 claude·grok 의 경고 없음(dry-run 과 행) 4건과 명시적 `--onboard` 전달 1건이다.
|
||||
|
||||
## 실행한 테스트와 결과
|
||||
|
||||
스냅샷에 `.venv` 가 없어 제어 평면 인터프리터를 썼다. cwd 는 스냅샷, basetemp 는 `/tmp/mam-i68-8900db9e` 이다.
|
||||
|
||||
```
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID -u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE -u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest \
|
||||
-p no:cacheprovider --basetemp=/tmp/mam-i68-8900db9e \
|
||||
tests/test_issue68_onboard_default.py \
|
||||
tests/test_issue21_acceptance.py \
|
||||
tests/test_issue21_phase2_loop_a.py \
|
||||
tests/test_issue26_ints.py \
|
||||
tests/test_issue42_skill_blocks.py \
|
||||
tests/test_issue50_onboard.py \
|
||||
tests/test_issue57_logs_follow_registry.py \
|
||||
-q --tb=line
|
||||
```
|
||||
|
||||
결과: `238 passed, 1 skipped, 1 error in 497.36s (0:08:17)`, 프로세스 종료 코드 1. 제품 단정이 실패한 테스트는 없다. `test_issue26_ints.py` 는 `MAM_ID_POST_DISCOVERED` 를 문자열 비교로 분류한다 (`:111`).
|
||||
|
||||
스킵 1건은 quiet 로그가 노드 이름을 찍지 않는다. 스냅샷에 `.claude/skills` 가 없고, 그 조건의 스킵은 `tests/test_issue42_skill_blocks.py:263-266` `test_claude_skills_link_resolves_to_agents_skills` 이다.
|
||||
|
||||
세션 종료 오류는 `tests/test_issue57_logs_follow_registry.py::test_a_symlinked_registry_dir_resolves_to_one_audit_log` 에 붙어 있고, 본문 단정이 아니다. `conftest.py:1581` 의 `agent_home_leak_guard` 가 세션 동안 생긴 agy 홈 항목 10개(brain 디렉터리 5, conversation db 5)를 보고했다. 가드는 이름을 읽고 지우지 않는다 (`conftest.py:1586-1592`). claude 만 예외이고 그 외 디렉터리는 예외가 없다 (`tests/_agent_home_guard.py:106`).
|
||||
|
||||
그 10개 항목의 mtime 은 pytest 구간(로그 생성 1793252159, 종료 1793252657, 약 14:36–14:44) 안이다. 다섯 db 를 연 프로세스는 pid 83251, `agy --hub`, 시작 13:50:53, 부모는 Antigravity IDE 헬퍼(82581)이다. pytest 자식이 아니다. 이번 테스트의 agy 경로는 `mock_agents` 이다. 파일은 열거나 지우지 않았고 그 자리에 두었다. 이 오류는 동작 중인 agy 가 자기 홈에 새 대화를 쓴 경우이고, 제품 결함으로 보지 않는다.
|
||||
|
||||
테스트 뒤 pytest 프로세스와 mock herdr, 추가 `reconcile.sh` 는 없었다. 체크아웃 모니터 pid 3714 (`reconcile.sh --subscribe --idle-timeout 0`, 부모 1) 는 그대로다. 신호를 보내지 않았다. `tests/test_issue50_dispatch_herdr.py` 는 실제 herdr 를 띄우므로 돌리지 않았다. 전체 스위트와 `test_issue34_job_tree.py` 도 돌리지 않았다.
|
||||
|
||||
## 발견 사항
|
||||
|
||||
차단: 없다.
|
||||
|
||||
비차단:
|
||||
|
||||
- `tests/test_issue68_onboard_default.py:17` 의 `import yaml` 는 쓰이지 않는다. 같은 파일의 `agent_sessions_yaml` 은 딕셔너리 키이다.
|
||||
- `SKILL.md:173` 은 같은 폴더의 같은 종류 create 가 막힌다고 쓰고, `create_session.sh:210` 은 그 생성이 기다린다고 쓴다 (`Issue #63`, `MAM_ID_ORDER_WAIT_S`). 종료 코드와 경고 조건은 스크립트와 결정이 같다.
|
||||
- 전달 테스트는 `[]`, `--onboard`, `--no-onboard`, `--no-onboard --onboard` 네 가지다 (`:186-193`). `--onboard --no-onboard` 순서는 같은 while 루프(`worktree_prepare.sh:31-43`)라 마지막이 이기고, 파라미터에는 없다.
|
||||
- 위의 세션 가드 오류. 재현하려면 동작 중인 agy 를 끄거나, 그 홈을 가드 범위 밖으로 두어야 한다. 제품 수정 대상은 아니다.
|
||||
|
||||
## 사용자 확인 필요
|
||||
|
||||
없다. 본문의 `--no-onboard` 삭제 제안은 2026-10-28 결정으로 대체됐고, 구현은 그 결정(플래그 유지, worktree 기본 onboarding, 네 에이전트 경고, 문서 최소 변경)을 따른다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261027T201245Z-63c71b",
|
||||
"base_sha": "1d63e882bc1c46e9af8484647d5091b296d8fa3f",
|
||||
"head_sha": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"tree_sha": "fbadb24de60ccdae684b23e2e0a41cdf23ab81eb",
|
||||
"review_commit": "ec7f5c4e57286fb3c4238b8220f39094f4640391",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "a43bca93",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-27T21:06:27Z"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# PR #75 (Issue #72) 리뷰 — 라운드 5
|
||||
|
||||
검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261027T201245Z-63c71b/1` 만 봤다. HEAD `ec7f5c4e57286fb3c4238b8220f39094f4640391`, tree `fbadb24de60ccdae684b23e2e0a41cdf23ab81eb`, base `1d63e882bc1c46e9af8484647d5091b296d8fa3f` (HEAD 의 부모이자 조상), porcelain 0. 스냅샷에는 `.venv` 가 없다. 테스트 뒤에도 HEAD·tree·porcelain 은 같았다. 제어 평면의 입장 잠금과 업데이트 표식은 없었고, 체크아웃 모니터 pid 3714 (`reconcile.sh --subscribe --idle-timeout 0`) 는 그대로였다.
|
||||
|
||||
이번 커밋은 `ec7f5c4` 하나다. `1d63e88..ec7f5c4` 는 `lib.sh` 와 `tests/test_issue72_update.py` 이고 `git diff --check` 는 깨끗했다. diff 에 `mapfile`, `declare -A`, `local -n`, `[[ -v`, `sed -i`, `stat -c`, `readlink -f`, `date -d` 는 없다. `setsid(1)` 도 호출하지 않는다.
|
||||
|
||||
## 확인한 것
|
||||
|
||||
R2. `MAM_DELEGATION_PY=0` 의 기동도 호출자 프로세스 그룹 밖에 둔다.
|
||||
|
||||
- `lib.sh:2297-2310`. 이미 떠 있는 모니터가 없을 때만, 호출자가 이미 `-m` 이었는지 기억하고 `set -m` 으로 잡은 뒤 `nohup bash <reconcile.sh> --subscribe --idle-timeout 0` 을 백그라운드로 띄운다. stdin 은 `/dev/null`, 로그는 append, 그 다음 호출자의 `-m` 을 되돌린다. 중복 확인(`lib.sh:2291` `_monitor_pids`)과 `MAM_NO_SUBSCRIBER_DAEMON=1` (`lib.sh:2295-2296`) 은 그 앞에 그대로다. PID 출력은 `lib.sh:2314-2315` 이다.
|
||||
- 이 함수를 쓰는 재시작은 세 곳이다. 설치 성공은 `post_update.sh:41`, 정지 시간 초과는 `update.sh:106`, 롤백과 상태 기록 실패는 `install_phase.sh:64` 의 `bash -c` 가 복구된 트리의 `start_watchdog` 를 부른다. `lib.sh:2567-2573` 에서 `MAM_DELEGATION_PY=0` 이면 레거시, 아니면 Python `start_new_session` 이다.
|
||||
- tty 없이 `set -euo pipefail` 인 bash 3.2 에서 `set -m` 으로 띄운 `sleep` 의 PGID 는 그 PID 와 같고 부모 PGID 와 달랐다. 부모가 끝난 뒤에도 그 프로세스는 남아 있었고, 확인 뒤에 그 PID 만 종료했다.
|
||||
- `test_issue72_update.py:697-725`. `MAM_DELEGATION_PY` 0 과 1, 설치 성공·TERM 무시 후 KILL·롤백 여섯 조합에서 새 모니터의 PGID 가 이 테스트 프로세스 그룹과 다른지 본다. `box.update` 는 프로세스 그룹을 바꾸지 않으므로 그 그룹이 호출자 그룹이다.
|
||||
|
||||
정리. 테스트는 패턴으로 죽이지 않는다.
|
||||
|
||||
- `tests/test_issue72_update.py` 에 `pkill` 과 `killall` 은 없다.
|
||||
- `test_issue72_update.py:221-240`. 이 테스트가 `helper` 로 띄운 프로세스 그룹만 `os.killpg` 하고, 이 임시 작업공간의 `reconcile.sh --subscribe` 명령줄과 그 `pgrep -P` 자식만 `os.kill` 한다. 그 다음 10초 동안 그 작업공간 경로를 명령줄에 가진 프로세스가 남아 있으면 실패한다 (`_processes_naming`, `184-193`). 읽는 용도이고, 다른 작업공간의 모니터는 경로가 다르므로 대상이 아니다.
|
||||
|
||||
이전 라운드에서 맞던 중복 방지, opt-out, PID 보고, 로그 append 는 이 diff 가 그 순서를 바꾸지 않았다. Python 쪽 세션 분리는 `tests/test_issue5_phase1_delegation.py:697-699` 가 그대로 본다.
|
||||
|
||||
## 실행한 테스트
|
||||
|
||||
작업 디렉터리는 스냅샷이다. 인터프리터는 제어 평면 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python` (Python 3.9.6). `--basetemp=/tmp/mam-i72-a43bca93`.
|
||||
|
||||
```
|
||||
env -u HERDR_SOCKET_PATH -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID \
|
||||
-u HERDR_SESSION_NAME -u HERDR_SESSION -u WORKSPACE_ROOT -u MAM_ENV_FILE \
|
||||
-u MAM_JOBS_DIR -u DELEGATE_JOB_LOGS_DIR \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python -m pytest \
|
||||
-p no:cacheprovider --basetemp=/tmp/mam-i72-a43bca93 \
|
||||
tests/test_issue72_admission.py tests/test_issue72_attach_hints.py \
|
||||
tests/test_issue72_installer.py tests/test_issue72_resume_monitor.py \
|
||||
tests/test_issue72_update.py tests/test_issue8_update_state.py \
|
||||
tests/test_deploy_layout.py tests/test_issue42_skill_blocks.py \
|
||||
tests/test_issue26_ints.py tests/test_version_consistency.py \
|
||||
tests/test_issue5_phase1_delegation.py \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py \
|
||||
-q --tb=line
|
||||
```
|
||||
|
||||
결과: **314 passed, 1 skipped**, 888.47s, 종료 코드 0. PGID 여섯 조합과 watchdog 수명 주기가 이 안에 들어 있다.
|
||||
|
||||
건너뛴 한 건은 `tests/test_issue42_skill_blocks.py:266` (`.claude/skills` 가 이 체크아웃에 없음). 같은 파일만 `--basetemp=/tmp/mam-i72-a43bca93-skip -rs` 로 다시 돌리면 39 passed, 1 skipped, 0.07s 이다.
|
||||
|
||||
basetemp 경로를 가진 프로세스는 종료 뒤에 남아 있지 않았다.
|
||||
|
||||
## 발견 사항
|
||||
|
||||
차단: 없음. 설계를 다시 잡아야 하는 결함도 없다.
|
||||
|
||||
비차단:
|
||||
|
||||
- 레거시 경로는 새 프로세스 그룹이다 (`set -m`). Python 경로는 `start_new_session` 으로 새 세션이다. 이번 라운드가 요구한 것은 호출자 프로세스 그룹에서 벗어나는 것이고, 둘 다 그렇게 한다. `nohup` 이 SIGHUP 을 무시하는 것은 그대로다.
|
||||
- `lib.sh:2310`. 모니터를 띄운 뒤 원래 디렉터리로 돌아가지 못하면 함수는 1 을 반환한다. 그 시점에는 이미 `set +m` 이 실행됐고 데몬은 떠 있다. 호출자는 PID 를 받지 못한다. 디렉터리가 사라진 경우에만 해당한다.
|
||||
- 앞 라운드의 비차단 항목(롤백 재시작이 `mam_monitor_started` 를 호출하지 않음, SIGKILL 자식 PID 재확인, 이미 떠 있는 resume 의 잠금 범위, 호출자의 실행 중 잡이 둘일 때)은 이 커밋이 건드리지 않았고 이번 수정 범위 밖이다.
|
||||
|
||||
전용 테스트가 없는 조건:
|
||||
|
||||
| 조건 | 코드 | 테스트 |
|
||||
| --- | --- | --- |
|
||||
| 레거시 모니터가 호출자와 다른 세션인지 | `lib.sh:2306-2308` 은 프로세스 그룹만 분리 | 이번 테스트는 PGID 만 비교 (`test_issue72_update.py:724`) |
|
||||
| 앞 라운드 표의 세 조건 | 변경 없음 | 이번 범위 밖 |
|
||||
|
||||
## 사용자 확인 필요
|
||||
|
||||
없음.
|
||||
|
||||
[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]
|
||||
+218
-60
@@ -29,33 +29,54 @@ export WORKSPACE_ROOT
|
||||
# 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.
|
||||
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
||||
MAM_VERSION="4.10.0"
|
||||
MAM_VERSION="4.13.0"
|
||||
export MAM_VERSION
|
||||
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() {
|
||||
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')
|
||||
local s=""
|
||||
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}" -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
|
||||
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)
|
||||
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)
|
||||
fi
|
||||
if [ -n "$h" ]; then
|
||||
s="${s:0:23}-$h"
|
||||
else
|
||||
s="${s:0:32}"
|
||||
if [ "${#h}" -ne 8 ] && command -v python3 >/dev/null 2>&1; then
|
||||
h=$(python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode("utf-8")).hexdigest()[:8])' "$s" 2>/dev/null || true)
|
||||
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
|
||||
printf '%s\n' "$s"
|
||||
}
|
||||
@@ -155,8 +176,10 @@ _init_herdr_isolation() {
|
||||
fi
|
||||
|
||||
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
|
||||
set -euo pipefail
|
||||
|
||||
@@ -290,31 +313,6 @@ _real_herdr() {
|
||||
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).
|
||||
#
|
||||
@@ -441,7 +439,7 @@ sys.exit(1)
|
||||
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
|
||||
fi
|
||||
local sat pid=""
|
||||
sat=$(_sanitize_herdr_agent_name "$target")
|
||||
sat=$(_sanitize_herdr_agent_name "$target") || return 1
|
||||
|
||||
local cand get_ws
|
||||
if [ "$#" -ge 2 ]; then
|
||||
@@ -534,7 +532,7 @@ case "$cmd" in
|
||||
_resolve_herdr_target() {
|
||||
local raw="$1"
|
||||
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
|
||||
echo "$sat"
|
||||
return 0
|
||||
@@ -604,6 +602,15 @@ sys.exit(1)
|
||||
exit 1
|
||||
fi
|
||||
_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
|
||||
_real_herdr agent "$sub" "$@"
|
||||
fi
|
||||
@@ -623,19 +630,15 @@ sys.exit(1)
|
||||
*) shift ;;
|
||||
esac
|
||||
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
|
||||
exit 0
|
||||
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
|
||||
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", "")
|
||||
stn = sanitize_herdr_agent_name(tn)
|
||||
stn = os.environ.get("TARGET_SAN", "")
|
||||
tws = os.environ.get("TARGET_WS", "")
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
@@ -682,6 +685,7 @@ sys.exit(1)
|
||||
*) run_cmd="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
agent_name=$(_sanitize_herdr_agent_name "$name") || exit 1
|
||||
if [ -z "$run_cmd" ]; then
|
||||
run_cmd="${SHELL:-/bin/bash}"
|
||||
fi
|
||||
@@ -896,7 +900,6 @@ except Exception:
|
||||
res=""
|
||||
success=0
|
||||
backoffs=(0.5 1 2)
|
||||
agent_name=$(_sanitize_herdr_agent_name "$name")
|
||||
for i in $(seq 0 2); do
|
||||
if [ -n "$final_cmd" ]; then
|
||||
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
|
||||
# "$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.
|
||||
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)
|
||||
if [ -n "$pane_id" ]; then
|
||||
_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
|
||||
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
||||
# 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)
|
||||
pane_id="" cwd="" cmd=""
|
||||
if [ -n "$info" ]; then
|
||||
@@ -1072,7 +1075,7 @@ except Exception:
|
||||
*) shift ;;
|
||||
esac
|
||||
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)
|
||||
if [ -n "$pane_id" ]; then
|
||||
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|
||||
@@ -1103,7 +1106,7 @@ except Exception:
|
||||
fi
|
||||
# `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).
|
||||
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)
|
||||
if [ -n "$pane_id" ]; then
|
||||
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
||||
@@ -1387,6 +1390,7 @@ for name, pid in rows:
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
} > "$tmp_file"
|
||||
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
|
||||
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).
|
||||
# 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() {
|
||||
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
env_python "$AGENT_SESSIONS_YAML" "MAM_STATE_READ=${1:-}" <<'PYEOF'
|
||||
import os, sys, sqlite3, json, yaml
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
strict = os.environ.get('MAM_STATE_READ') == 'strict'
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
d = {}
|
||||
db_sessions = []
|
||||
@@ -1603,20 +1611,24 @@ try:
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
if strict:
|
||||
raise
|
||||
try:
|
||||
cursor = conn.execute('SELECT data FROM sessions')
|
||||
for r in cursor.fetchall():
|
||||
db_sessions.append(json.loads(r[0]))
|
||||
d['herdr_sessions'] = db_sessions
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
if strict:
|
||||
raise
|
||||
conn.close()
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
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
|
||||
def clean_surrogates(obj):
|
||||
if isinstance(obj, str):
|
||||
@@ -1659,6 +1671,22 @@ print(resolved)
|
||||
"
|
||||
}
|
||||
|
||||
# session_attach_command <session_name> [server]
|
||||
#
|
||||
# Issue #72: the attach hint a script prints - the session row's
|
||||
# attach_command, else the one lib_py.agents.sanitize.attach_command builds for
|
||||
# <server> (default: `default`).
|
||||
session_attach_command() {
|
||||
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$1" ATTACH_SERVER="${2:-default}" python3 -c "
|
||||
import os, json
|
||||
from lib_py.agents.sanitize import attach_command
|
||||
name = os.environ['SESSION_NAME']
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
|
||||
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
|
||||
print(row.get('attach_command') or attach_command(os.environ['ATTACH_SERVER'], name))
|
||||
"
|
||||
}
|
||||
|
||||
# resolve_herdr_session <session_name> [workspace]
|
||||
#
|
||||
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
|
||||
@@ -2207,6 +2235,41 @@ _delegate_publish_event_legacy() {
|
||||
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
||||
}
|
||||
|
||||
# _monitor_pids <monitor_script>
|
||||
# Issue #72: PIDs whose command line is exactly `<bash> <monitor_script>
|
||||
# --subscribe[ ...]` - the shell twin of lib_py.delegation.daemon.find_running,
|
||||
# so both start_watchdog branches recognise the same daemons. Not a `pgrep -f`
|
||||
# regex: that also matched another workspace's daemon (an unescaped `.`) and a
|
||||
# process that only names the path in an argument.
|
||||
_monitor_pids() {
|
||||
ps -A -ww -o pid= -o args= | MAM_MONITOR_WANT="$1 --subscribe" awk '
|
||||
{ line = $0; sub(/^ +/, "", line)
|
||||
i = index(line, " "); if (!i) next
|
||||
pid = substr(line, 1, i - 1); args = substr(line, i + 1)
|
||||
j = index(args, " "); if (!j) next
|
||||
n = split(substr(args, 1, j - 1), a0, "/"); rest = substr(args, j + 1)
|
||||
want = ENVIRON["MAM_MONITOR_WANT"]
|
||||
if (a0[n] == "bash" && (rest == want || index(rest, want " ") == 1)) print pid }'
|
||||
}
|
||||
|
||||
# mam_monitor_started <workspace> <pid...>
|
||||
# Issue #72: 0 once one of the PIDs start_watchdog printed is WORKSPACE's
|
||||
# monitor (_monitor_pids), so a restart is only reported when it happened.
|
||||
# Polled for up to 5 s: a daemon just started from the shell carries its
|
||||
# parent's command line until it execs bash.
|
||||
mam_monitor_started() {
|
||||
local script="$1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" n=0 p
|
||||
shift
|
||||
while [ "$n" -lt 50 ]; do
|
||||
for p in $(_monitor_pids "$script"); do
|
||||
case " $* " in *" $p "*) return 0 ;; esac
|
||||
done
|
||||
sleep 0.1
|
||||
n=$((n + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# start_watchdog <job_id> [workdir]
|
||||
# Spawns a watchdog process to monitor a delegate-job JOB in the background.
|
||||
# The watchdog re-spawns the subscriber every 2 minutes (or whatever hard
|
||||
@@ -2225,7 +2288,7 @@ _start_watchdog_legacy() {
|
||||
|
||||
# Check if reconcile.sh --subscribe is already running on this workspace
|
||||
local pid
|
||||
pid=$(pgrep -f "bash $monitor_script --subscribe" || true)
|
||||
pid=$(_monitor_pids "$monitor_script" || true)
|
||||
|
||||
# Issue #19: MAM_NO_SUBSCRIBER_DAEMON=1 reports a running daemon but never
|
||||
# starts one (test sandboxes; externally supervised monitors).
|
||||
@@ -2234,10 +2297,16 @@ _start_watchdog_legacy() {
|
||||
elif [ -z "$pid" ]; then
|
||||
# Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out)
|
||||
# and ensure it runs with $workdir as cwd to anchor relative log paths.
|
||||
local orig_pwd="$PWD"
|
||||
# Issue #72 (decision 2): job control (`set -m`) starts it in its own
|
||||
# process group, out of the caller's, as the python body's new session
|
||||
# does; macOS has no setsid(1). The caller's option is restored.
|
||||
local orig_pwd="$PWD" had_m=0
|
||||
case $- in *m*) had_m=1 ;; esac
|
||||
cd "$workdir" || return 1
|
||||
set -m
|
||||
nohup bash "$monitor_script" --subscribe --idle-timeout 0 </dev/null >> "$log_file" 2>&1 &
|
||||
pid=$!
|
||||
[ "$had_m" = 1 ] || set +m
|
||||
cd "$orig_pwd" || return 1
|
||||
fi
|
||||
|
||||
@@ -2787,7 +2856,7 @@ wait_for_agent_idle() {
|
||||
fi
|
||||
|
||||
local target
|
||||
target=$(_sanitize_herdr_agent_name "$sess")
|
||||
target=$(_sanitize_herdr_agent_name "$sess") || return 3
|
||||
|
||||
local raw_status="" agent_st=""
|
||||
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||
@@ -2912,7 +2981,7 @@ send_keys_safe() {
|
||||
fi
|
||||
|
||||
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)
|
||||
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
||||
@@ -3219,3 +3288,92 @@ mam_control_plane_prefix() {
|
||||
[ -n "${1:-}" ] || return 0
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #72 (decision 8): the update admission gate.
|
||||
#
|
||||
# The update skill publishes .mam/update-in-progress (pid + lstart, loop_lock.sh
|
||||
# rules) while it holds .mam/update-admission.lock exclusively, then runs its
|
||||
# pre-check. Every entry that starts something the pre-check must see - create,
|
||||
# resume, run_loop.sh, delegate submit - or writes the registry - stop,
|
||||
# orc-onboard - takes that lock shared on fd 7, refuses while the marker is
|
||||
# `live` or `unknown`, and keeps fd 7 until its record is written
|
||||
# (mam_update_admit_release). An update therefore either waits for that record
|
||||
# and sees it, or publishes first and the entry refuses. The lock is a BSD
|
||||
# flock taken by lib_py.pane_lock on the open fd: it ends when every process
|
||||
# holding fd 7 has ended, however that ends, so nothing long-lived may inherit
|
||||
# fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by
|
||||
# MAM_UPDATE_ADMISSION_WAIT_S (default 120).
|
||||
#
|
||||
# mam_update_admit [workspace [fd]]: rc 0 admitted (fd 7, or the given one,
|
||||
# held unless the workspace has no .mam), 1 refused (stderr). Delegate submit
|
||||
# also takes the workspaces its target is in on fds 20 and up, create and
|
||||
# resume those their pane's cwd is in (mam_update_admit_at).
|
||||
mam_update_admit() {
|
||||
local root="${1:-$WORKSPACE_ROOT}" fd="${2:-7}"
|
||||
local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st
|
||||
# No .mam: not an installed workspace, which is all the update runs in (and a
|
||||
# checkout running its own scripts gets no .mam from this).
|
||||
[ -d "$root/.mam" ] || return 0
|
||||
if ! eval "exec $fd>>\"\$lock\""; then
|
||||
echo "ERROR: cannot open the update admission lock $lock" >&2
|
||||
return 1
|
||||
fi
|
||||
case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd "$fd" --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in
|
||||
locked) ;;
|
||||
timeout)
|
||||
mam_update_admit_release "$fd"
|
||||
echo "ERROR: an update of $root holds its admission lock (MAM_UPDATE_ADMISSION_WAIT_S passed); try again when it has finished" >&2
|
||||
return 1 ;;
|
||||
*)
|
||||
mam_update_admit_release "$fd"
|
||||
echo "ERROR: the update admission lock $lock could not be decided (lib_py.pane_lock)" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
[ -f "$marker" ] || return 0
|
||||
# shellcheck disable=SC1091
|
||||
declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
|
||||
st="$(_mam_lock_holder_state "$marker")"
|
||||
case "$st" in
|
||||
stale)
|
||||
echo "NOTE: stale update marker $marker (its update is gone); ignored" >&2
|
||||
return 0 ;;
|
||||
esac
|
||||
mam_update_admit_release "$fd"
|
||||
echo "ERROR: an update of $root is in progress (marker $marker, holder $st); try again when it has finished" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
mam_update_admit_release() {
|
||||
eval "exec ${1:-7}>&-"
|
||||
}
|
||||
|
||||
# mam_update_admit_at <cwd>: what create and resume take for an agent they
|
||||
# start in <cwd> - mam_update_admit for the control root (fd 7) and for every
|
||||
# other installed workspace at or above <cwd> (fds 20 and up), whose update
|
||||
# counts that agent (decision 1). rc 1 refused, nothing held.
|
||||
# mam_update_admit_release_all drops them all; a long-lived child started
|
||||
# while they are held runs it first.
|
||||
MAM_UPDATE_ADMIT_FDS=""
|
||||
mam_update_admit_at() {
|
||||
local roots root fd=20
|
||||
roots="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.update target-workspaces \
|
||||
--workspace "$WORKSPACE_ROOT" --cwd "$1")" \
|
||||
|| { echo "ERROR: cannot resolve the installed workspaces at or above $1" >&2; return 1; }
|
||||
mam_update_admit || return 1
|
||||
while IFS= read -r root; do
|
||||
[ -n "$root" ] || continue
|
||||
if ! mam_update_admit "$root" "$fd"; then
|
||||
mam_update_admit_release_all
|
||||
return 1
|
||||
fi
|
||||
MAM_UPDATE_ADMIT_FDS="$MAM_UPDATE_ADMIT_FDS $fd"; fd=$((fd + 1))
|
||||
done <<< "$roots"
|
||||
}
|
||||
|
||||
mam_update_admit_release_all() {
|
||||
local fd
|
||||
mam_update_admit_release
|
||||
for fd in $MAM_UPDATE_ADMIT_FDS; do mam_update_admit_release "$fd"; done
|
||||
MAM_UPDATE_ADMIT_FDS=""
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
|
||||
|
||||
import os, sys, json, shlex, time
|
||||
from lib_py.agents.base import POST_DISCOVERED
|
||||
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
||||
|
||||
# `purge auto` probes adapters in this order; opencode last because its
|
||||
@@ -71,6 +72,7 @@ def main():
|
||||
print(f"MAM_AGENT_STATUS_RELIABLE={1 if adapter.agent_status_reliable else 0}")
|
||||
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||
print(f"MAM_ID_POST_DISCOVERED={1 if adapter.id_lifecycle == POST_DISCOVERED else 0}")
|
||||
|
||||
elif cmd == 'resolve':
|
||||
name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||
|
||||
@@ -197,11 +197,14 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
# the DB already gone, and those orphans must still be reachable.
|
||||
return any(os.path.exists(p) for p in self._artifact_paths(uuid, ctx))
|
||||
|
||||
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
return self._artifact_paths(uuid, ctx)
|
||||
|
||||
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
# The holder may keep the presence lock (or a sidecar) open rather
|
||||
# than the DB, and may name the uuid nowhere; the lsof leg of the
|
||||
# check must see every path of the conversation.
|
||||
return conversation_in_use(uuid, self._artifact_paths(uuid, ctx))
|
||||
return conversation_in_use(uuid, self.in_use_paths(uuid, ctx))
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
# Issue #10: a purge must leave nothing resumable behind. Besides the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os, json, glob, sqlite3, subprocess
|
||||
import os, json, glob, shutil, sqlite3, subprocess
|
||||
from typing import Optional, Any, List
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _plausible_id, _ro_connect, scan_names, db_rows_naming
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
class CodexAgentAdapter(BaseAgentAdapter):
|
||||
@@ -131,6 +131,22 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _purge_tui_thread_capability(self, uuid: str, ctx: DiscoveryContext, purged: List[str]) -> None:
|
||||
if not _plausible_id(uuid):
|
||||
return
|
||||
cap = os.path.join(self._codex_home(ctx), "tui-thread-reference-capabilities", uuid)
|
||||
if not os.path.lexists(cap):
|
||||
return
|
||||
try:
|
||||
if os.path.isdir(cap) and not os.path.islink(cap):
|
||||
shutil.rmtree(cap)
|
||||
else:
|
||||
os.unlink(cap)
|
||||
if not os.path.lexists(cap):
|
||||
purged.append(cap)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
|
||||
purged = []
|
||||
path = self._find_rollout(uuid, ctx)
|
||||
@@ -140,12 +156,16 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
if res.returncode == 0:
|
||||
if path:
|
||||
purged.append(path)
|
||||
self._purge_tui_thread_capability(uuid, ctx, purged)
|
||||
return purged
|
||||
except Exception:
|
||||
pass
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
purged.append(path)
|
||||
except Exception:
|
||||
pass
|
||||
db = self._state_db(ctx)
|
||||
if db and os.path.exists(db):
|
||||
try:
|
||||
@@ -156,6 +176,7 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
purged.append(f"state db row for thread: {uuid}")
|
||||
except Exception:
|
||||
pass
|
||||
self._purge_tui_thread_capability(uuid, ctx, purged)
|
||||
return purged
|
||||
|
||||
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
|
||||
|
||||
@@ -13,6 +13,11 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
|
||||
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
|
||||
_DB_PATHS = {}
|
||||
|
||||
|
||||
def _is_shared_service(cmd: str) -> bool:
|
||||
"""The per-user opencode background service (`serve --service`)."""
|
||||
return "serve --service" in (cmd or "")
|
||||
|
||||
class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -250,6 +255,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
|
||||
return found
|
||||
|
||||
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
db = self._resolve_db(ctx)
|
||||
return [db] if db else []
|
||||
|
||||
def _holder_counts(self, cmd: str) -> bool:
|
||||
return not _is_shared_service(cmd)
|
||||
|
||||
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
"""A process naming the session, or an opencode process holding the
|
||||
shared database. The per-user background service (`serve --service`)
|
||||
@@ -270,13 +282,14 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||
timeout=10).stdout
|
||||
except Exception:
|
||||
return True
|
||||
if cmd.strip() and "serve --service" not in cmd:
|
||||
if cmd.strip() and not _is_shared_service(cmd):
|
||||
return True
|
||||
return False
|
||||
|
||||
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
db = self._resolve_db(ctx)
|
||||
return super().in_use_hints(uuid, ctx) + [
|
||||
f"another opencode session holding {self._resolve_db(ctx)}: lsof {self._resolve_db(ctx)}"]
|
||||
f"another opencode session holding {db}: lsof {db}"]
|
||||
|
||||
# opencode 2.x: the default agent is build (`--agent` is gone from the
|
||||
# TUI), and --standalone keeps the server a child of the pane's TUI
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
|
||||
|
||||
import os, json, sqlite3, shutil, subprocess, tempfile
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from urllib.parse import quote
|
||||
from lib_py.paths import resolve_home
|
||||
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||
@@ -86,6 +86,34 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def file_holders(paths=()) -> List[Tuple[int, str]]:
|
||||
"""(pid, command_line) for each process holding one of `paths` open via lsof (#78).
|
||||
Informational only: returns [] when lsof is absent or fails."""
|
||||
existing = [p for p in paths if p and os.path.exists(p)]
|
||||
if not existing or not shutil.which("lsof"):
|
||||
return []
|
||||
try:
|
||||
r = subprocess.run(["lsof", "-t"] + existing, capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return []
|
||||
pids: List[int] = []
|
||||
for tok in (r.stdout or "").split():
|
||||
if tok.isdigit():
|
||||
pid = int(tok)
|
||||
if pid not in pids:
|
||||
pids.append(pid)
|
||||
out: List[Tuple[int, str]] = []
|
||||
for pid in pids:
|
||||
try:
|
||||
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
|
||||
capture_output=True, text=True, timeout=10).stdout.strip()
|
||||
except Exception:
|
||||
continue
|
||||
if cmd:
|
||||
out.append((pid, cmd))
|
||||
return out
|
||||
|
||||
|
||||
def _plausible_id(uuid: str) -> bool:
|
||||
"""An id safe to build a path from and to search for: not empty, not
|
||||
made of dots alone ("." names the parent dir itself), no "/", ".." or "*"."""
|
||||
@@ -354,14 +382,25 @@ class BaseAgentAdapter:
|
||||
path = self.artifact_path(uuid, ctx)
|
||||
return uuid in path and os.path.exists(path)
|
||||
|
||||
def in_use_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""On-disk paths checked with lsof for conversation_in_use and in_use_hints (#78)."""
|
||||
return [self.artifact_path(uuid, ctx)]
|
||||
|
||||
def _holder_counts(self, cmd: str) -> bool:
|
||||
return True
|
||||
|
||||
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
"""True while a live process still references the conversation."""
|
||||
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
|
||||
return conversation_in_use(uuid, self.in_use_paths(uuid, ctx))
|
||||
|
||||
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""Where to look for what holds the conversation, shown when a purge
|
||||
is refused because it is in use."""
|
||||
return [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
|
||||
hints = [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
|
||||
for pid, cmd in file_holders(self.in_use_paths(uuid, ctx)):
|
||||
if self._holder_counts(cmd):
|
||||
hints.append(f"held open by pid {pid}: {cmd} (not stopped by MAM; end it yourself if it is safe)")
|
||||
return hints
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -341,7 +341,8 @@ def wait(cwd: str, agent: str, session: str, owner_pid: int,
|
||||
print(
|
||||
f"ERROR: {m['session']}'s create/resume exited before recording it, "
|
||||
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,
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
|
||||
def sanitize_herdr_agent_name(name: str) -> str:
|
||||
"""
|
||||
@@ -29,3 +30,14 @@ def sanitize_herdr_agent_name(name: str) -> str:
|
||||
|
||||
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
|
||||
from datetime import datetime, timezone
|
||||
import yaml
|
||||
from lib_py import registry_format
|
||||
|
||||
def atomic_dump_yaml_main():
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
@@ -114,6 +115,27 @@ def atomic_dump_yaml_main():
|
||||
elif 'herdr_sessions' not in d:
|
||||
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)
|
||||
# a mutation that replaces a row the monitor registered for a pane it
|
||||
# created itself (create_session.sh) drops that name from old_roles
|
||||
@@ -167,7 +189,7 @@ def atomic_dump_yaml_main():
|
||||
|
||||
conn.commit()
|
||||
|
||||
if new_sessions != old_sessions:
|
||||
if force_yaml or new_sessions != old_sessions:
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
shutil.copy2(yaml_path, yaml_path + '.bak')
|
||||
@@ -207,6 +229,7 @@ def atomic_dump_yaml_main():
|
||||
_flock_f.close()
|
||||
except Exception:
|
||||
pass
|
||||
return from_version, registry_format.stored_version(d), migrated_rows
|
||||
|
||||
if __name__ == '__main__':
|
||||
atomic_dump_yaml_main()
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Behaviour is byte-for-byte compatible with the shell original (issue #5 phase 1,
|
||||
D5): same monitor path, same stderr wording and exit status when it is missing,
|
||||
same `pgrep -f` pre-check pattern, same log file, same "print the PID" contract.
|
||||
the same daemons found by the pre-check (matched exactly since Issue #72, see
|
||||
find_running), same log file, same "print the PID" contract.
|
||||
Callers capture stdout with $( ), so nothing else may be printed there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -39,20 +40,34 @@ def monitor_script_path(workdir: str) -> str:
|
||||
def find_running(monitor_script: str) -> List[str]:
|
||||
"""PIDs of subscribers already running for this monitor script.
|
||||
|
||||
Uses the exact `pgrep -f` pattern the shell helper used, so a daemon started
|
||||
by either implementation is found by the other. Multiple matches are returned
|
||||
in full: the legacy helper echoed every line and callers only ever echo it
|
||||
back, so trimming here would be a silent behaviour change.
|
||||
A subscriber is a process whose command line is `<bash> <monitor_script>
|
||||
--subscribe[ ...]`, argv0 a bash, as both this module and the shell helper
|
||||
start it. Issue #72: matched exactly (lib.sh _monitor_pids is the shell
|
||||
twin the legacy helper uses), not as a `pgrep -f` regex, because the update skill signals what this returns - a daemon of
|
||||
another workspace the unescaped path also matched, or a process that only
|
||||
names the path in an argument, is not this workspace's monitor. Multiple
|
||||
matches are returned in full: the legacy helper echoed every line and
|
||||
callers only ever echo it back, so trimming here would be a silent
|
||||
behaviour change.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["pgrep", "-f", "bash {} --subscribe".format(monitor_script)],
|
||||
["ps", "-A", "-ww", "-o", "pid=", "-o", "args="],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
except OSError as exc: # pragma: no cover - pgrep missing
|
||||
logger.warning("pgrep unavailable: %s", exc)
|
||||
except OSError as exc: # pragma: no cover - ps missing
|
||||
logger.warning("ps unavailable: %s", exc)
|
||||
return []
|
||||
return [line for line in res.stdout.split() if line.strip()]
|
||||
want = "{} --subscribe".format(monitor_script)
|
||||
pids = []
|
||||
for line in res.stdout.splitlines():
|
||||
parts = line.strip().split(" ", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
argv0, _, rest = parts[1].partition(" ")
|
||||
if os.path.basename(argv0) == "bash" and (rest == want or rest.startswith(want + " ")):
|
||||
pids.append(parts[0])
|
||||
return pids
|
||||
|
||||
|
||||
def _ignore_sighup(): # pragma: no cover - runs in the forked child
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
from typing import List, Sequence
|
||||
|
||||
# Framework version locked with framework constants
|
||||
FRAMEWORK_VERSION = "4.10.0"
|
||||
FRAMEWORK_VERSION = "4.13.0"
|
||||
|
||||
CORE_MODULES: Sequence[str] = (
|
||||
# Standard library modules
|
||||
|
||||
@@ -18,6 +18,9 @@ one lock per server from the choice to the claim.
|
||||
pane_lock.py acquire --fd N flock(LOCK_EX) on the inherited fd N; prints
|
||||
`locked`, or `timeout` once MAM_PANE_CLAIM_WAIT_S
|
||||
(default 120, minimum 1) has passed
|
||||
[--shared] [--wait-env VAR] Issue #72: LOCK_SH instead, and the wait read
|
||||
from VAR (same default and minimum) - the
|
||||
update admission lock (lib.sh mam_update_admit)
|
||||
|
||||
The lock is a BSD flock, which belongs to the open file on fd 9: it outlives
|
||||
this helper and ends when the last process holding that fd ends, however it
|
||||
@@ -63,11 +66,11 @@ def lock_path(session: Optional[str], env: Mapping[str, str]) -> str:
|
||||
return os.path.join(home, ".local", "state", "mam", "pane-lock", session + ".lock")
|
||||
|
||||
|
||||
def wait_s(env: Mapping[str, str]) -> int:
|
||||
return ints.positive_int(env.get("MAM_PANE_CLAIM_WAIT_S"), 1) or DEFAULT_WAIT_S
|
||||
def wait_s(env: Mapping[str, str], var: str = "MAM_PANE_CLAIM_WAIT_S") -> int:
|
||||
return ints.positive_int(env.get(var), 1) or DEFAULT_WAIT_S
|
||||
|
||||
|
||||
def acquire(fd: int, wait: float) -> str:
|
||||
def acquire(fd: int, wait: float, shared: bool = False) -> str:
|
||||
"""`locked`, or `timeout` when the lock was not free within `wait` s. The
|
||||
deadline is on the monotonic clock; a step of the wall clock does not
|
||||
stretch or cut it. Only contention is waited out: any other error (a bad
|
||||
@@ -75,7 +78,7 @@ def acquire(fd: int, wait: float) -> str:
|
||||
deadline = time.monotonic() + wait
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
|
||||
return "locked"
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
@@ -90,11 +93,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
s.add_argument("--session", default="")
|
||||
s = sub.add_parser("acquire")
|
||||
s.add_argument("--fd", type=int, required=True)
|
||||
s.add_argument("--shared", action="store_true")
|
||||
s.add_argument("--wait-env", default="MAM_PANE_CLAIM_WAIT_S")
|
||||
a = p.parse_args(argv)
|
||||
if a.cmd == "path":
|
||||
print(lock_path(a.session, os.environ))
|
||||
else:
|
||||
print(acquire(a.fd, wait_s(os.environ)))
|
||||
print(acquire(a.fd, wait_s(os.environ, a.wait_env), a.shared))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,812 @@
|
||||
"""Decisions behind the multi-agent-mux-reset skill (Issue #78).
|
||||
|
||||
reset.sh keeps the shell sequence (arg parsing, terminal check, update
|
||||
admission, loop lock, sequential stop/create invocations, exit codes) and
|
||||
delegates target selection, prechecks, atomic spec-file persistence, 5-state
|
||||
progress classification, and result-table formatting to this module.
|
||||
Standard library only, Python 3.9 compatible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
from lib_py.agents.base import AGENT_SESSION_ENV
|
||||
from lib_py.agents.registry import agent_of_row, get_adapter, own_key
|
||||
from lib_py.update import open_jobs, row_server
|
||||
|
||||
SPEC_VERSION = 1
|
||||
SPEC_REL = os.path.join(".mam", "reset-spec.json")
|
||||
|
||||
|
||||
class SpecError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _real(p: str) -> str:
|
||||
return os.path.realpath(p) if p else ""
|
||||
|
||||
|
||||
def spec_path(workspace: str) -> str:
|
||||
return os.path.join(_real(workspace), SPEC_REL)
|
||||
|
||||
|
||||
def _state(workspace: str = "") -> dict:
|
||||
raw = os.environ.get("MAM_STATE_JSON")
|
||||
if raw is not None:
|
||||
return json.loads(raw or "{}") or {}
|
||||
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or (
|
||||
os.path.join(_real(workspace), ".mam", "agent-sessions.yaml") if workspace else ""
|
||||
)
|
||||
if not yaml_path:
|
||||
return {}
|
||||
db_path = os.path.splitext(yaml_path)[0] + ".db"
|
||||
if os.path.exists(db_path):
|
||||
tmp_dir = tempfile.mkdtemp(prefix="mam-reset-ro.")
|
||||
try:
|
||||
tmp_db = os.path.join(tmp_dir, "agent-sessions.db")
|
||||
shutil.copyfile(db_path, tmp_db)
|
||||
for suf in ("-wal", "-shm"):
|
||||
if os.path.exists(db_path + suf):
|
||||
shutil.copyfile(db_path + suf, tmp_db + suf)
|
||||
conn = sqlite3.connect(tmp_db, timeout=60.0)
|
||||
try:
|
||||
d: dict = {}
|
||||
row = conn.execute("SELECT data FROM state WHERE id=1").fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
cursor = conn.execute("SELECT data FROM sessions")
|
||||
d["herdr_sessions"] = [json.loads(r[0]) for r in cursor.fetchall()]
|
||||
return d
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
raise ValueError("the registry %s cannot be read (%s); fix it first" % (yaml_path, exc))
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if data is not None and not isinstance(data, dict):
|
||||
raise ValueError("top-level YAML is not a mapping")
|
||||
return data or {}
|
||||
except Exception as exc:
|
||||
raise ValueError("the registry %s cannot be read (%s); fix it first" % (yaml_path, exc))
|
||||
return {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D2: target selection
|
||||
|
||||
def select_targets(workspace: str, state: dict) -> dict:
|
||||
"""Select running control-root sessions and classify non-targets.
|
||||
|
||||
Returns {"targets": [...], "skipped": [{"name", "reason"}],
|
||||
"orchestrators": [...], "errors": [...]}.
|
||||
"""
|
||||
ws_real = _real(workspace)
|
||||
targets: List[dict] = []
|
||||
skipped: List[dict] = []
|
||||
errors: List[str] = []
|
||||
|
||||
for row in state.get("herdr_sessions") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
name = str(row.get("name") or "")
|
||||
status = str(row.get("status") or "")
|
||||
if status != "running":
|
||||
skipped.append({"name": name, "reason": "status %s" % (status or "unknown")})
|
||||
continue
|
||||
|
||||
cwd = str((row.get("pane") or {}).get("cwd") or "")
|
||||
cwd_real = _real(cwd) if cwd else ""
|
||||
if cwd_real != ws_real:
|
||||
skipped.append({
|
||||
"name": name,
|
||||
"reason": "pane.cwd %s is not the control root (a worktree creator?)" % (cwd or "<empty>"),
|
||||
})
|
||||
continue
|
||||
|
||||
agent = agent_of_row(row)
|
||||
role = str(row.get("role") or "").strip()
|
||||
if not agent:
|
||||
errors.append(
|
||||
"running session %s has no recognisable agent type (agent=%r, pane.cmd=%r); "
|
||||
"fix its registry row first"
|
||||
% (name, row.get("agent"), (row.get("pane") or {}).get("cmd"))
|
||||
)
|
||||
continue
|
||||
if not role:
|
||||
errors.append(
|
||||
"running session %s (%s) has an empty role; fix its registry row first"
|
||||
% (name, agent)
|
||||
)
|
||||
continue
|
||||
|
||||
key = own_key(agent)
|
||||
raw_id = row.get(key)
|
||||
old_id = str(raw_id).strip() if isinstance(raw_id, str) and raw_id.strip() else None
|
||||
targets.append({
|
||||
"name": name,
|
||||
"agent": agent,
|
||||
"role": role,
|
||||
"herdr_server": str(row_server(row)),
|
||||
"herdr_workspace": str(row.get("herdr_workspace") or ""),
|
||||
"old_id": old_id,
|
||||
"old_created_at": str(row.get("herdr_session_created_at") or ""),
|
||||
})
|
||||
|
||||
orchestrators = [str(u) for u in (state.get("orchestrator_uuids") or []) if u]
|
||||
return {
|
||||
"targets": targets,
|
||||
"skipped": skipped,
|
||||
"orchestrators": orchestrators,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D5: spec file
|
||||
|
||||
def write_spec(workspace: str, agents: List[dict]) -> str:
|
||||
"""Atomically write .mam/reset-spec.json (mkstemp -> fsync -> replace -> dir fsync)."""
|
||||
ws_real = _real(workspace)
|
||||
mam_dir = os.path.join(ws_real, ".mam")
|
||||
os.makedirs(mam_dir, exist_ok=True)
|
||||
path = os.path.join(mam_dir, "reset-spec.json")
|
||||
now_iso = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
doc = {
|
||||
"version": SPEC_VERSION,
|
||||
"workspace": ws_real,
|
||||
"written_at": now_iso,
|
||||
"agents": agents,
|
||||
}
|
||||
payload = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".reset-spec.", suffix=".tmp", dir=mam_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(payload)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
dir_fd = os.open(mam_dir, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def read_spec(workspace: str) -> dict:
|
||||
"""Read and validate .mam/reset-spec.json; raise SpecError if invalid."""
|
||||
ws_real = _real(workspace)
|
||||
path = spec_path(ws_real)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
doc = json.load(fh)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SpecError("spec file %s cannot be read (%s); check it, then remove it by hand" % (path, exc))
|
||||
|
||||
if not isinstance(doc, dict) or doc.get("version") != SPEC_VERSION:
|
||||
raise SpecError(
|
||||
"spec file %s has unsupported version %r; check it, then remove it by hand"
|
||||
% (path, doc.get("version") if isinstance(doc, dict) else None)
|
||||
)
|
||||
spec_ws = _real(str(doc.get("workspace") or ""))
|
||||
if spec_ws != ws_real:
|
||||
raise SpecError(
|
||||
"spec file %s is for workspace %r, not %r; check it, then remove it by hand"
|
||||
% (path, doc.get("workspace"), ws_real)
|
||||
)
|
||||
agents = doc.get("agents")
|
||||
if not isinstance(agents, list):
|
||||
raise SpecError("spec file %s has no agents list; check it, then remove it by hand" % path)
|
||||
for item in agents:
|
||||
if not isinstance(item, dict):
|
||||
raise SpecError("spec file %s has a non-object agent entry; check it, then remove it by hand" % path)
|
||||
name = item.get("name")
|
||||
agent = item.get("agent")
|
||||
role = item.get("role")
|
||||
if not (isinstance(name, str) and name and isinstance(agent, str) and agent and isinstance(role, str) and role):
|
||||
raise SpecError("spec file %s has an incomplete agent entry; check it, then remove it by hand" % path)
|
||||
adapter = get_adapter(agent)
|
||||
if adapter is None or adapter.name != agent or not own_key(agent):
|
||||
raise SpecError(
|
||||
"spec file %s has unknown agent %r; check it, then remove it by hand" % (path, agent)
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def record_old_id(workspace: str, state: dict, session_name: str) -> Optional[str]:
|
||||
"""Persist a newly captured conversation id onto an entry with old_id=None.
|
||||
|
||||
Called after step 1 (normal stop) captures a POST_DISCOVERED session's id
|
||||
onto the original instance's registry row, so a subsequent resume + rerun
|
||||
cannot mistake the original conversation for a new one (review F1).
|
||||
Reads the id from `state` (never from command-line args).
|
||||
"""
|
||||
ws_real = _real(workspace)
|
||||
spec = read_spec(ws_real)
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
row = rows_by_name.get(session_name)
|
||||
if row is None:
|
||||
return None
|
||||
changed = False
|
||||
recorded: Optional[str] = None
|
||||
for entry in spec["agents"]:
|
||||
if str(entry.get("name") or "") != session_name:
|
||||
continue
|
||||
raw_old = entry.get("old_id")
|
||||
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
|
||||
if old_id:
|
||||
return old_id
|
||||
cur_created_at = str(row.get("herdr_session_created_at") or "")
|
||||
old_created_at = str(entry.get("old_created_at") or "")
|
||||
if cur_created_at != old_created_at:
|
||||
return None
|
||||
key = own_key(entry["agent"])
|
||||
raw_cur = row.get(key)
|
||||
cur_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else None
|
||||
if cur_id:
|
||||
entry["old_id"] = cur_id
|
||||
recorded = cur_id
|
||||
changed = True
|
||||
if changed:
|
||||
write_spec(ws_real, spec["agents"])
|
||||
return recorded
|
||||
|
||||
|
||||
def new_running_rows(workspace: str, state: dict, spec_agents: List[dict]) -> List[str]:
|
||||
"""Names of running control-root rows not present in spec_agents."""
|
||||
known = {str(a.get("name") or "") for a in spec_agents}
|
||||
sel = select_targets(workspace, state)
|
||||
return [t["name"] for t in sel["targets"] if t["name"] not in known]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D6: progress classification
|
||||
|
||||
def classify_entry(entry: dict, row: Optional[dict]) -> Tuple[str, Optional[str], bool]:
|
||||
"""Classify a spec entry against its current registry row (matched by name only).
|
||||
|
||||
Returns (r_state, new_id, same_instance):
|
||||
1. row is None -> ("purged", None, False)
|
||||
2. r.herdr_session_created_at == old_created_at -> ("pending", None, True)
|
||||
3. not same_instance and r[id] == old_id (non-empty) -> ("pending", cur_id, False)
|
||||
4. not same_instance and r[id] is empty -> ("created", None, False)
|
||||
5. not same_instance and r[id] is non-empty and != old_id -> ("done", cur_id, False)
|
||||
"""
|
||||
if row is None:
|
||||
return "purged", None, False
|
||||
|
||||
key = own_key(entry["agent"])
|
||||
cur_created_at = str(row.get("herdr_session_created_at") or "")
|
||||
old_created_at = str(entry.get("old_created_at") or "")
|
||||
raw_cur = row.get(key)
|
||||
cur_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else None
|
||||
raw_old = entry.get("old_id")
|
||||
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
|
||||
|
||||
if cur_created_at == old_created_at:
|
||||
return "pending", None, True
|
||||
if cur_id and old_id and cur_id == old_id:
|
||||
return "pending", cur_id, False
|
||||
if not cur_id:
|
||||
return "created", None, False
|
||||
return "done", cur_id, False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D4: precheck
|
||||
|
||||
def _ancestor_pids(start_pid: Optional[int] = None) -> set:
|
||||
pid = os.getpid() if start_pid is None else start_pid
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ps", "-eo", "pid=,ppid="],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return set()
|
||||
if res.returncode != 0:
|
||||
return set()
|
||||
ppid_of: Dict[int, int] = {}
|
||||
for line in (res.stdout or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
ppid_of[int(parts[0])] = int(parts[1])
|
||||
ancestors = set()
|
||||
cur = pid
|
||||
while cur and cur > 1 and cur not in ancestors:
|
||||
ancestors.add(cur)
|
||||
cur = ppid_of.get(cur, 0)
|
||||
return ancestors
|
||||
|
||||
|
||||
def _caller_blockers(
|
||||
targets: List[dict],
|
||||
rows_by_name: Mapping[str, dict],
|
||||
env: Mapping[str, str],
|
||||
ancestors: Optional[set] = None,
|
||||
) -> List[str]:
|
||||
env_ids = {
|
||||
str(env.get(k) or "").strip()
|
||||
for k in AGENT_SESSION_ENV
|
||||
if str(env.get(k) or "").strip()
|
||||
}
|
||||
anc = _ancestor_pids() if ancestors is None else ancestors
|
||||
out: List[str] = []
|
||||
for t in targets:
|
||||
name = t["name"]
|
||||
row = rows_by_name.get(name)
|
||||
r_state, _, _ = classify_entry(t, row)
|
||||
if r_state != "pending":
|
||||
continue
|
||||
old_id = t.get("old_id")
|
||||
key = own_key(t["agent"])
|
||||
raw_cur = (row or {}).get(key)
|
||||
cur_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else None
|
||||
|
||||
matched = False
|
||||
if env_ids and ((old_id and old_id in env_ids) or (cur_id and cur_id in env_ids)):
|
||||
matched = True
|
||||
elif row is not None and anc:
|
||||
pane_pid = (row.get("pane") or {}).get("pid")
|
||||
child_pid = row.get("child_pid")
|
||||
for p in (pane_pid, child_pid):
|
||||
if isinstance(p, int) and p > 1 and p in anc:
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
out.append("you are %s, one of the targets; run the reset from the orchestrator" % name)
|
||||
return out
|
||||
|
||||
|
||||
def precheck(
|
||||
workspace: str,
|
||||
state: dict,
|
||||
env: Mapping[str, str],
|
||||
ancestors: Optional[set] = None,
|
||||
) -> List[str]:
|
||||
"""Return refusal lines for open jobs, unreadable spec, un-recreatable targets, or caller==target."""
|
||||
ws_real = _real(workspace)
|
||||
blockers: List[str] = []
|
||||
|
||||
spath = spec_path(ws_real)
|
||||
targets: List[dict] = []
|
||||
if os.path.lexists(spath):
|
||||
try:
|
||||
spec = read_spec(ws_real)
|
||||
targets = spec["agents"]
|
||||
except SpecError as exc:
|
||||
blockers.append(str(exc))
|
||||
else:
|
||||
sel = select_targets(ws_real, state)
|
||||
blockers.extend(sel["errors"])
|
||||
targets = sel["targets"]
|
||||
|
||||
for j in open_jobs(ws_real, state, env):
|
||||
if j.get("dir"):
|
||||
blockers.append(
|
||||
"jobs directory %s cannot be listed; fix its permissions, then try again"
|
||||
% j["path"]
|
||||
)
|
||||
elif j.get("unreadable"):
|
||||
blockers.append(
|
||||
"job record %s cannot be read; check it, then move or delete it by hand"
|
||||
% j["path"]
|
||||
)
|
||||
else:
|
||||
blockers.append(
|
||||
"job %s (%s, %s) is open; wait for it to end or close it: python "
|
||||
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
|
||||
" --registry-dir %s close --job %s --request cancelled"
|
||||
% (
|
||||
j["job_id"],
|
||||
j["agent_session"] or "-",
|
||||
j["status"],
|
||||
j["registry_dir"],
|
||||
j["job_id"],
|
||||
)
|
||||
)
|
||||
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
blockers.extend(_caller_blockers(targets, rows_by_name, env, ancestors=ancestors))
|
||||
return blockers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D1: entries shell lines
|
||||
|
||||
def format_entry_line(entry: dict, row: Optional[dict]) -> str:
|
||||
r_state, _, _ = classify_entry(entry, row)
|
||||
key = own_key(entry["agent"])
|
||||
raw_cur = (row or {}).get(key)
|
||||
row_id = str(raw_cur).strip() if isinstance(raw_cur, str) and raw_cur.strip() else ""
|
||||
pairs = [
|
||||
("R_STATE", r_state),
|
||||
("R_NAME", str(entry.get("name") or "")),
|
||||
("R_AGENT", str(entry.get("agent") or "")),
|
||||
("R_ROLE", str(entry.get("role") or "")),
|
||||
("R_SERVER", str(entry.get("herdr_server") or "default")),
|
||||
("R_LABEL", str(entry.get("herdr_workspace") or "")),
|
||||
("R_OLD_ID", str(entry.get("old_id") or "")),
|
||||
("R_ROW_ID", row_id),
|
||||
]
|
||||
return " ".join("%s=%s" % (k, shlex.quote(v)) for k, v in pairs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D8: result table
|
||||
|
||||
def _parse_skipped(items: Optional[List[str]]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for raw in items or []:
|
||||
for token in raw.split(","):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
if ":" in token:
|
||||
name, kind = token.split(":", 1)
|
||||
out[name] = kind
|
||||
else:
|
||||
out[token] = "purge_refused"
|
||||
return out
|
||||
|
||||
|
||||
def _parse_failed(raw: Optional[str]) -> Optional[Tuple[str, str, str]]:
|
||||
if not raw:
|
||||
return None
|
||||
parts = raw.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
return parts[0], parts[1], parts[2]
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1], "1"
|
||||
return parts[0], "stop", "1"
|
||||
|
||||
|
||||
def build_table(
|
||||
agents: List[dict],
|
||||
state: dict,
|
||||
skipped_items: Optional[List[str]] = None,
|
||||
failed_spec: Optional[str] = None,
|
||||
) -> Tuple[str, int]:
|
||||
"""Format the D8 result table and compute the D9 exit code (0, 1, 4, or 5)."""
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
skipped_map = _parse_skipped(skipped_items)
|
||||
failed = _parse_failed(failed_spec)
|
||||
failed_name = failed[0] if failed else None
|
||||
after_failed = False
|
||||
|
||||
headers = ("NAME", "AGENT", "OLD_ID", "NEW_ID", "RECORDED", "NEW≠OLD", "RESULT")
|
||||
table_rows: List[Tuple[str, str, str, str, str, str, str]] = []
|
||||
|
||||
has_skip_or_incomplete = bool(skipped_map)
|
||||
has_created = False
|
||||
|
||||
for entry in agents:
|
||||
name = str(entry.get("name") or "")
|
||||
agent = str(entry.get("agent") or "")
|
||||
raw_old = entry.get("old_id")
|
||||
old_id = str(raw_old).strip() if isinstance(raw_old, str) and raw_old.strip() else None
|
||||
old_col = old_id if old_id else "-"
|
||||
|
||||
row = rows_by_name.get(name)
|
||||
r_state, new_id, _ = classify_entry(entry, row)
|
||||
new_col = new_id if new_id else "-"
|
||||
recorded_col = "yes" if new_id else "no"
|
||||
if not new_id:
|
||||
diff_col = "-"
|
||||
elif not old_id or new_id != old_id:
|
||||
diff_col = "yes"
|
||||
else:
|
||||
diff_col = "no"
|
||||
|
||||
if failed and name == failed_name:
|
||||
result_col = "failed at %s (rc %s)" % (failed[1], failed[2])
|
||||
after_failed = True
|
||||
has_skip_or_incomplete = True
|
||||
elif after_failed and r_state in ("pending", "purged"):
|
||||
result_col = "not reached"
|
||||
has_skip_or_incomplete = True
|
||||
elif name in skipped_map:
|
||||
kind = skipped_map[name]
|
||||
if kind == "no_id":
|
||||
result_col = "skipped: no conversation id recorded"
|
||||
else:
|
||||
result_col = "skipped: purge refused"
|
||||
has_skip_or_incomplete = True
|
||||
elif r_state == "done":
|
||||
result_col = "done"
|
||||
elif r_state == "created":
|
||||
result_col = "created; id not recorded yet"
|
||||
has_created = True
|
||||
elif r_state == "purged":
|
||||
result_col = "purged"
|
||||
has_skip_or_incomplete = True
|
||||
else:
|
||||
result_col = "pending"
|
||||
has_skip_or_incomplete = True
|
||||
|
||||
table_rows.append((name, agent, old_col, new_col, recorded_col, diff_col, result_col))
|
||||
|
||||
widths = [len(h) for h in headers]
|
||||
for r in table_rows:
|
||||
for i in range(len(headers) - 1):
|
||||
if len(r[i]) > widths[i]:
|
||||
widths[i] = len(r[i])
|
||||
|
||||
def _fmt(cols: Tuple[str, ...]) -> str:
|
||||
parts = [cols[i].ljust(widths[i]) for i in range(len(cols) - 1)]
|
||||
parts.append(cols[-1])
|
||||
return " ".join(parts)
|
||||
|
||||
lines = [_fmt(headers)]
|
||||
for r in table_rows:
|
||||
lines.append(_fmt(r))
|
||||
|
||||
if failed:
|
||||
rc = 1
|
||||
elif has_skip_or_incomplete:
|
||||
rc = 4
|
||||
elif has_created:
|
||||
rc = 5
|
||||
else:
|
||||
rc = 0
|
||||
return "\n".join(lines), rc
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- D10: dry-run
|
||||
|
||||
def format_dry_run(
|
||||
workspace: str,
|
||||
state: dict,
|
||||
env: Mapping[str, str],
|
||||
extra_refusals: Optional[List[str]] = None,
|
||||
) -> Tuple[str, int]:
|
||||
ws_real = _real(workspace)
|
||||
lines: List[str] = []
|
||||
refusals: List[str] = list(extra_refusals or [])
|
||||
refusals.extend(precheck(ws_real, state, env))
|
||||
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
sel = select_targets(ws_real, state)
|
||||
spath = spec_path(ws_real)
|
||||
action_map = {
|
||||
"pending": "stop + purge + create",
|
||||
"purged": "create",
|
||||
"created": "none (waiting for id)",
|
||||
"done": "none (done)",
|
||||
}
|
||||
|
||||
if os.path.lexists(spath):
|
||||
try:
|
||||
spec = read_spec(ws_real)
|
||||
except SpecError:
|
||||
spec = None
|
||||
if spec is not None:
|
||||
lines.append("spec file: %s" % spath)
|
||||
for e in spec["agents"]:
|
||||
r_state, _, _ = classify_entry(e, rows_by_name.get(e["name"]))
|
||||
lines.append(
|
||||
"target: %s agent=%s role=%s herdr_server=%s herdr_workspace=%s "
|
||||
"old_id=%s old_created_at=%s state=%s action=%s"
|
||||
% (
|
||||
e["name"],
|
||||
e["agent"],
|
||||
e["role"],
|
||||
e.get("herdr_server") or "default",
|
||||
e.get("herdr_workspace") or "",
|
||||
e.get("old_id") or "-",
|
||||
e.get("old_created_at") or "-",
|
||||
r_state,
|
||||
action_map.get(r_state, r_state),
|
||||
)
|
||||
)
|
||||
extra_running = new_running_rows(ws_real, state, spec["agents"])
|
||||
if extra_running:
|
||||
lines.append(
|
||||
"NOTE: running control-root session(s) not in reset-spec.json "
|
||||
"(not targeted by this rerun): %s" % ", ".join(extra_running)
|
||||
)
|
||||
else:
|
||||
if sel["targets"]:
|
||||
for t in sel["targets"]:
|
||||
lines.append(
|
||||
"target: %s agent=%s role=%s herdr_server=%s herdr_workspace=%s "
|
||||
"old_id=%s old_created_at=%s"
|
||||
% (
|
||||
t["name"],
|
||||
t["agent"],
|
||||
t["role"],
|
||||
t["herdr_server"],
|
||||
t["herdr_workspace"],
|
||||
t["old_id"] or "-",
|
||||
t["old_created_at"] or "-",
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append("targets: (none)")
|
||||
|
||||
for sk in sel["skipped"]:
|
||||
lines.append("skip: %s (%s)" % (sk["name"], sk["reason"]))
|
||||
for u in sel["orchestrators"]:
|
||||
lines.append("orchestrator %s (not a row; never reset)" % u)
|
||||
|
||||
if refusals:
|
||||
lines.append("would refuse:")
|
||||
for r in refusals:
|
||||
lines.append(" - %s" % r)
|
||||
return "\n".join(lines), 1
|
||||
return "\n".join(lines), 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- CLI
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="lib_py.reset")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("plan")
|
||||
s.add_argument("--workspace", required=True)
|
||||
|
||||
s = sub.add_parser("precheck")
|
||||
s.add_argument("--workspace", required=True)
|
||||
|
||||
s = sub.add_parser("write-spec")
|
||||
s.add_argument("--workspace", required=True)
|
||||
|
||||
s = sub.add_parser("entries")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--session", default="")
|
||||
s.add_argument("--warn-new", action="store_true")
|
||||
|
||||
s = sub.add_parser("record-id")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--session", required=True)
|
||||
|
||||
s = sub.add_parser("table")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--skipped", action="append", default=[])
|
||||
s.add_argument("--failed", default="")
|
||||
|
||||
s = sub.add_parser("dry-run")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--refusal", action="append", default=[])
|
||||
|
||||
a = p.parse_args(argv)
|
||||
ws = _real(a.workspace)
|
||||
|
||||
if a.cmd == "write-spec":
|
||||
try:
|
||||
raw = json.load(sys.stdin)
|
||||
except ValueError as exc:
|
||||
print("ERROR: invalid targets JSON on stdin (%s)" % exc, file=sys.stderr)
|
||||
return 1
|
||||
agents = raw.get("targets") if isinstance(raw, dict) else raw
|
||||
if not isinstance(agents, list):
|
||||
print("ERROR: targets JSON must be a list", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
write_spec(ws, agents)
|
||||
except OSError as exc:
|
||||
print("ERROR: cannot write %s (%s)" % (spec_path(ws), exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
try:
|
||||
state = _state(ws)
|
||||
except ValueError as exc:
|
||||
if a.cmd == "dry-run":
|
||||
refs = list(a.refusal or []) + [str(exc)]
|
||||
out, rc = format_dry_run(ws, {}, os.environ, extra_refusals=refs)
|
||||
print(out)
|
||||
return rc
|
||||
if a.cmd == "precheck":
|
||||
print("registry: cannot parse MAM_STATE_JSON (%s)" % exc)
|
||||
return 1
|
||||
print("ERROR: cannot parse MAM_STATE_JSON (%s)" % exc, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if a.cmd == "plan":
|
||||
sel = select_targets(ws, state)
|
||||
print(json.dumps(sel))
|
||||
return 1 if sel["errors"] else 0
|
||||
|
||||
if a.cmd == "precheck":
|
||||
lines = precheck(ws, state, os.environ)
|
||||
for line in lines:
|
||||
print(line)
|
||||
return 1 if lines else 0
|
||||
|
||||
if a.cmd == "entries":
|
||||
try:
|
||||
spec = read_spec(ws)
|
||||
except SpecError as exc:
|
||||
print("ERROR: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
if a.warn_new:
|
||||
extra = new_running_rows(ws, state, spec["agents"])
|
||||
if extra:
|
||||
print(
|
||||
"NOTE: running control-root session(s) not in %s (not targeted by this rerun): %s"
|
||||
% (spec_path(ws), ", ".join(extra)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
rows_by_name = {
|
||||
str(s.get("name")): s
|
||||
for s in (state.get("herdr_sessions") or [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
}
|
||||
for e in spec["agents"]:
|
||||
if a.session and e.get("name") != a.session:
|
||||
continue
|
||||
print(format_entry_line(e, rows_by_name.get(e["name"])))
|
||||
return 0
|
||||
|
||||
if a.cmd == "record-id":
|
||||
try:
|
||||
record_old_id(ws, state, a.session)
|
||||
except (SpecError, OSError) as exc:
|
||||
print("ERROR: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if a.cmd == "table":
|
||||
try:
|
||||
spec = read_spec(ws)
|
||||
except SpecError as exc:
|
||||
print("ERROR: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
out, rc = build_table(
|
||||
spec["agents"],
|
||||
state,
|
||||
skipped_items=a.skipped,
|
||||
failed_spec=a.failed or None,
|
||||
)
|
||||
print(out)
|
||||
return rc
|
||||
|
||||
if a.cmd == "dry-run":
|
||||
out, rc = format_dry_run(ws, state, os.environ, extra_refusals=a.refusal)
|
||||
print(out)
|
||||
return rc
|
||||
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,6 +38,7 @@ from typing import List, Optional
|
||||
|
||||
from lib_py.agents.input_region import extract_input_region
|
||||
from lib_py.agents.registry import agent_of_row, get_adapter
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||
from lib_py.delegation.engine import TERMINAL_STATUSES
|
||||
from lib_py.loop import watcher
|
||||
|
||||
@@ -154,8 +155,12 @@ class HerdrError(Exception):
|
||||
class HerdrSource(object):
|
||||
"""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
|
||||
# `name` is the name herdr knows the agent by (#66), worked out once
|
||||
# by StallWatch (#71); the kind is read off the original name, whose
|
||||
# suffix a long name's hash replaces.
|
||||
self.name = name
|
||||
self.agent = resolve_agent(agent, target)
|
||||
adapter = get_adapter(self.agent) if self.agent else None
|
||||
self.resident = tuple(adapter.resident_children) if adapter else ()
|
||||
@@ -176,7 +181,7 @@ class HerdrSource(object):
|
||||
return proc.stdout
|
||||
|
||||
def agent_get(self):
|
||||
payload = json.loads(self._herdr("agent", "get", self.target) or "{}")
|
||||
payload = json.loads(self._herdr("agent", "get", self.name) or "{}")
|
||||
return (payload.get("result") or {}).get("agent") or {}
|
||||
|
||||
def agent_pid(self, pane_id):
|
||||
@@ -199,12 +204,12 @@ class HerdrSource(object):
|
||||
if not self.agent:
|
||||
return None
|
||||
try:
|
||||
return extract_input_region(self._herdr("agent", "read", self.target), self.agent) == ""
|
||||
return extract_input_region(self._herdr("agent", "read", self.name), self.agent) == ""
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def prompt(self, text):
|
||||
self._herdr("agent", "prompt", self.target, text)
|
||||
self._herdr("agent", "prompt", self.name, text)
|
||||
|
||||
def collect(self, sig: Signals) -> Signals:
|
||||
"""Fill the process and input signals of an idle agent."""
|
||||
@@ -221,7 +226,8 @@ class HerdrSource(object):
|
||||
class StallWatch(object):
|
||||
def __init__(self, args, source=None):
|
||||
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.src = source or HerdrSource(args.target, args.agent, args.herdr_session, name=self.herdr_name)
|
||||
name = resolve_agent(args.agent, args.target)
|
||||
adapter = get_adapter(name) if name else None
|
||||
reliable = bool(adapter.agent_status_reliable) if adapter else True
|
||||
@@ -323,7 +329,7 @@ class StallWatch(object):
|
||||
until, tmo = ("idle", "done", "working"), 60000
|
||||
else:
|
||||
until, tmo = ("idle", "done", "blocked"), 60000
|
||||
out = watcher.wait_once(self.args.target, until=until, timeout_ms=tmo, session=self.args.herdr_session)
|
||||
out = watcher.wait_once(self.herdr_name, until=until, timeout_ms=tmo, session=self.args.herdr_session)
|
||||
if self._wrapper_gone(): # round-2 review D2: before any notice
|
||||
return RC_TERMINAL
|
||||
if out.kind in (watcher.TRANSPORT_ERROR, watcher.AGENT_NOT_FOUND):
|
||||
@@ -425,7 +431,7 @@ def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="notify-only stall watcher for one local herdr job (#30)")
|
||||
p.add_argument("--job", required=True)
|
||||
p.add_argument("--registry-dir", required=True)
|
||||
p.add_argument("--target", required=True, help="herdr agent name of the job's session")
|
||||
p.add_argument("--target", required=True, help="session name of the job (herdr is called with its sanitized form)")
|
||||
p.add_argument("--agent", required=True, help="agent CLI (adapter name)")
|
||||
p.add_argument("--herdr-session", default="")
|
||||
p.add_argument("--expect-dispatch-token", default=None)
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Decisions behind the multi-agent-mux-update skill (Issue #72).
|
||||
|
||||
update.sh keeps the orchestration in shell (phase 1: this tree's code) and asks
|
||||
this module what needs parsing. Standard library only, Python 3.9.
|
||||
|
||||
python -m lib_py.update precheck --workspace W --herdr H
|
||||
What refuses the update (comment 921, decisions 1 and 7): the running
|
||||
rows of the registry (MAM_STATE_JSON, from lib.sh `load_state_json
|
||||
strict`), every live herdr agent whose cwd is inside W on any server
|
||||
`herdr session list --json` reports running (read only: the real herdr H,
|
||||
never the shim, so no server is started), and the open jobs. The agent
|
||||
that called the update - by (server, pane): pane_lock.caller_server and
|
||||
HERDR_PANE_ID - is not counted, nor its row and its one running job.
|
||||
Prints one line per blocker with how to clear it; rc 0 nothing blocks,
|
||||
1 something does (a herdr or registry that cannot be read included).
|
||||
python -m lib_py.update monitor --workspace W
|
||||
JSON {"pids": [...], "lock": "free"|"held"}: the monitor daemons
|
||||
daemon.find_running recognises, and whether .mam/monitor.lock is held.
|
||||
python -m lib_py.update target-workspaces --workspace W --agent-session S --server V --herdr H
|
||||
The workspaces other than W whose update admission a delegate submit
|
||||
to `herdr:<name>` must take (decision 8), one per line: every installed
|
||||
workspace at or above the target's cwd (its row's pane.cwd; with no row
|
||||
the live agent's on server V) and its row's control_plane
|
||||
workspace_root. Nothing for a non-herdr target. rc 1: herdr cannot
|
||||
answer for a rowless target.
|
||||
python -m lib_py.update target-workspaces --workspace W --cwd C
|
||||
The same for an agent create or resume starts in C: every installed
|
||||
workspace at or above C other than W.
|
||||
python -m lib_py.update backup-registry --workspace W --dest D
|
||||
Copies the registry into D under the registry's own write lock
|
||||
(agent-sessions.db.lock, as atomic_yaml takes it): the DB through the
|
||||
SQLite backup API (commits still in -wal included), the YAML and its
|
||||
.bak as files. Prints the names it copied.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
||||
from lib_py.delegation import daemon
|
||||
from lib_py.delegation.engine import TERMINAL_STATUSES
|
||||
from lib_py.pane_lock import caller_server
|
||||
|
||||
REGISTRY_FILES = ("agent-sessions.yaml", "agent-sessions.yaml.bak", "agent-sessions.db")
|
||||
HERDR_TIMEOUT_S = 20
|
||||
|
||||
|
||||
def _real(p: str) -> str:
|
||||
return os.path.realpath(p)
|
||||
|
||||
|
||||
def _inside(path: str, root: str) -> bool:
|
||||
if not path:
|
||||
return False
|
||||
p, r = _real(path), _real(root).rstrip(os.sep)
|
||||
return p == r or p.startswith(r + os.sep)
|
||||
|
||||
|
||||
def row_server(row: dict) -> str:
|
||||
return row.get("herdr_session") or row.get("herdr_server") or "default"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- herdr
|
||||
|
||||
class HerdrError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _herdr_json(herdr: str, args: List[str], env: Mapping[str, str]) -> dict:
|
||||
# A pane's own scope ids would narrow the answer to the caller's workspace.
|
||||
cenv = {k: v for k, v in env.items()
|
||||
if k not in ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")}
|
||||
try:
|
||||
res = subprocess.run([herdr, *args], env=cenv, stdin=subprocess.DEVNULL,
|
||||
capture_output=True, text=True, timeout=HERDR_TIMEOUT_S)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise HerdrError("`herdr %s` failed: %s" % (" ".join(args), exc))
|
||||
if res.returncode != 0:
|
||||
raise HerdrError("`herdr %s` exited %d: %s" % (" ".join(args), res.returncode,
|
||||
(res.stderr or "").strip()[:200]))
|
||||
try:
|
||||
out = res.stdout
|
||||
return json.loads(out[out.index("{"):])
|
||||
except ValueError:
|
||||
raise HerdrError("`herdr %s` printed no JSON" % " ".join(args))
|
||||
|
||||
|
||||
def running_servers(herdr: str, env: Mapping[str, str]) -> List[str]:
|
||||
d = _herdr_json(herdr, ["session", "list", "--json"], env)
|
||||
sessions = d.get("sessions")
|
||||
if not isinstance(sessions, list):
|
||||
raise HerdrError("`herdr session list --json` has no sessions list")
|
||||
return [s["name"] for s in sessions
|
||||
if isinstance(s, dict) and s.get("running") and s.get("name")]
|
||||
|
||||
|
||||
def server_agents(herdr: str, server: str, env: Mapping[str, str]) -> List[dict]:
|
||||
d = _herdr_json(herdr, ["--session", server, "agent", "list"], env)
|
||||
agents = (d.get("result") or {}).get("agents")
|
||||
if not isinstance(agents, list):
|
||||
raise HerdrError("`herdr --session %s agent list` has no agents list" % server)
|
||||
return [a for a in agents if isinstance(a, dict)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- jobs
|
||||
|
||||
def _job_records(d: str) -> Iterable[Tuple[str, Optional[dict]]]:
|
||||
"""(path, record) per *.json; (path, None) when it cannot be read, and
|
||||
(d, None) when d itself cannot be listed (not an empty registry)."""
|
||||
try:
|
||||
names = sorted(n for n in os.listdir(d) if n.endswith(".json") and not n.startswith("."))
|
||||
except OSError:
|
||||
yield d, None
|
||||
return
|
||||
for p in (os.path.join(d, n) for n in names):
|
||||
try:
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
rec = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
yield p, None
|
||||
continue
|
||||
yield p, rec if isinstance(rec, dict) else None
|
||||
|
||||
|
||||
def _is_jobs_dir(d: str) -> bool:
|
||||
"""d is a directory, or cannot be looked at (an unreadable parent): only an
|
||||
absent d (or a file) is skipped."""
|
||||
try:
|
||||
os.stat(d)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
return os.path.isdir(d)
|
||||
|
||||
|
||||
def _is_open(rec: dict) -> bool:
|
||||
return rec.get("status") not in TERMINAL_STATUSES and rec.get("dry_run") is not True
|
||||
|
||||
|
||||
def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict]:
|
||||
"""Every open record (not completed/error/cancelled, not dry_run) in this
|
||||
workspace's .mam/jobs and MAM_JOBS_DIR; in another row's
|
||||
control_plane.mam_jobs_dir only those addressed to a row of this registry.
|
||||
A record, or a directory, that cannot be read is open (path only)."""
|
||||
own, seen = [], set()
|
||||
for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or ""):
|
||||
if c and _is_jobs_dir(c) and _real(c) not in seen:
|
||||
seen.add(_real(c))
|
||||
own.append(c)
|
||||
rows = state.get("herdr_sessions") or []
|
||||
names = {"herdr:%s" % s.get("name") for s in rows if s.get("name")}
|
||||
other = []
|
||||
for s in rows:
|
||||
c = (s.get("control_plane") or {}).get("mam_jobs_dir") or ""
|
||||
if c and _is_jobs_dir(c) and _real(c) not in seen:
|
||||
seen.add(_real(c))
|
||||
other.append(c)
|
||||
out = []
|
||||
for dirs, addressed_only in ((own, False), (other, True)):
|
||||
for d in dirs:
|
||||
for path, rec in _job_records(d):
|
||||
if rec is None:
|
||||
out.append({"path": path, "unreadable": True, "dir": path == d})
|
||||
elif _is_open(rec) and (not addressed_only or rec.get("agent_session") in names):
|
||||
out.append({"path": path, "job_id": rec.get("job_id") or "",
|
||||
"agent_session": rec.get("agent_session") or "",
|
||||
"status": rec.get("status"), "registry_dir": d})
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- precheck
|
||||
|
||||
def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> List[str]:
|
||||
"""The lines that refuse the update; [] when nothing does."""
|
||||
blockers: List[str] = []
|
||||
stop = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s"
|
||||
" (resume it after the update with multi-agent-mux-resume)")
|
||||
# A rowless agent: stop finds no row to take its server from (#71).
|
||||
stop_rowless = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s"
|
||||
" --herdr-session %s")
|
||||
|
||||
# The caller (decision 7): its server and pane, both or nothing.
|
||||
me_server, me_pane = caller_server(env), env.get("HERDR_PANE_ID") or ""
|
||||
me_name: Optional[str] = None
|
||||
me_found = False
|
||||
|
||||
agents_by_server: Dict[str, List[dict]] = {}
|
||||
try:
|
||||
for server in running_servers(herdr, env):
|
||||
agents_by_server[server] = server_agents(herdr, server, env)
|
||||
except HerdrError as exc:
|
||||
blockers.append("herdr: cannot list the live agents (%s); fix herdr and try again" % exc)
|
||||
|
||||
if me_server and me_pane:
|
||||
for a in agents_by_server.get(me_server, []):
|
||||
if a.get("pane_id") == me_pane:
|
||||
me_found = True
|
||||
me_name = a.get("name") or None # a nameless pane is excluded by pane only
|
||||
break
|
||||
|
||||
rows = state.get("herdr_sessions") or []
|
||||
me_row = None
|
||||
if me_name:
|
||||
for s in rows:
|
||||
if (s.get("status") == "running" and row_server(s) == me_server
|
||||
and s.get("name") and sanitize_herdr_agent_name(s["name"]) == me_name):
|
||||
me_row = s
|
||||
break
|
||||
|
||||
for s in rows:
|
||||
if s.get("status") == "running" and s is not me_row:
|
||||
blockers.append("session %s is running (server %s); stop it: %s"
|
||||
% (s.get("name"), row_server(s), stop % shlex.quote(s.get("name") or "")))
|
||||
|
||||
row_keys = {(row_server(s), sanitize_herdr_agent_name(s["name"]))
|
||||
for s in rows if s.get("status") == "running" and s.get("name")}
|
||||
for server, agents in agents_by_server.items():
|
||||
for a in agents:
|
||||
if me_found and server == me_server and a.get("pane_id") == me_pane:
|
||||
continue
|
||||
if not (_inside(a.get("cwd") or "", workspace)
|
||||
or _inside(a.get("foreground_cwd") or "", workspace)):
|
||||
continue
|
||||
name = a.get("name") or ""
|
||||
if name and (server, name) in row_keys:
|
||||
continue # already listed as its running row
|
||||
blockers.append("herdr agent %s (pane %s, server %s) runs in %s; stop it: %s"
|
||||
% (name or "<unnamed>", a.get("pane_id"), server, a.get("cwd"),
|
||||
stop_rowless % (shlex.quote(name), shlex.quote(server)) if name else
|
||||
"herdr --session %s pane close %s"
|
||||
% (shlex.quote(server), shlex.quote(a.get("pane_id") or ""))))
|
||||
|
||||
jobs = open_jobs(workspace, state, env)
|
||||
if me_row is not None:
|
||||
mine = [j for j in jobs if not j.get("unreadable")
|
||||
and j.get("agent_session") == "herdr:%s" % me_row.get("name")
|
||||
and j.get("status") == "running"]
|
||||
if len(mine) == 1:
|
||||
jobs = [j for j in jobs if j is not mine[0]]
|
||||
for j in jobs:
|
||||
if j.get("dir"):
|
||||
blockers.append("jobs directory %s cannot be listed; fix its permissions, then try again"
|
||||
% j["path"])
|
||||
elif j.get("unreadable"):
|
||||
blockers.append("job record %s cannot be read; check it, then move or delete it by hand"
|
||||
% j["path"])
|
||||
else:
|
||||
blockers.append("job %s (%s, %s) is open; wait for it to end or close it: python "
|
||||
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
|
||||
" --registry-dir %s close --job %s --request cancelled"
|
||||
% (j["job_id"], j["agent_session"] or "-", j["status"],
|
||||
j["registry_dir"], j["job_id"]))
|
||||
|
||||
if blockers and not me_found:
|
||||
blockers.append("note: no herdr agent was found at the caller's server %r and pane %r, so"
|
||||
" nothing was excluded as the calling agent" % (me_server, me_pane))
|
||||
return blockers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- delegate target
|
||||
|
||||
def _installed(d: str) -> bool:
|
||||
"""update.sh's own test of an installed workspace."""
|
||||
return os.path.isfile(os.path.join(d, ".mam_deploy", "remove.sh")) or os.path.isfile(os.path.join(d, "remove.sh"))
|
||||
|
||||
|
||||
def target_workspaces(workspace: str, state: dict, agent_session: str, server: str,
|
||||
herdr: str, env: Mapping[str, str]) -> List[str]:
|
||||
"""Every installed workspace at or above the target's cwd - an update of
|
||||
any of them counts that agent (decision 1) - and its row's
|
||||
control_plane.workspace_root; the caller's own workspace left out. With
|
||||
no row the cwd is the live agent's on <server> (the one delivery uses),
|
||||
read with the real herdr. No herdr, a server that is not running, or no
|
||||
such agent gives nothing (delivery then fails on its own); a herdr that
|
||||
cannot answer raises HerdrError."""
|
||||
if not agent_session.startswith("herdr:"):
|
||||
return []
|
||||
name = agent_session[len("herdr:"):]
|
||||
row = next((s for s in state.get("herdr_sessions") or []
|
||||
if isinstance(s, dict) and s.get("name") == name), None)
|
||||
cwds, out = [], []
|
||||
if row is not None:
|
||||
cwds.append((row.get("pane") or {}).get("cwd") or "")
|
||||
root = (row.get("control_plane") or {}).get("workspace_root") or ""
|
||||
if root:
|
||||
out.append(_real(root))
|
||||
elif herdr and server in running_servers(herdr, env):
|
||||
for a in server_agents(herdr, server, env):
|
||||
if a.get("name") in (name, sanitize_herdr_agent_name(name)):
|
||||
cwds += [a.get("cwd") or "", a.get("foreground_cwd") or ""]
|
||||
break
|
||||
return _others(workspace, out + installed_above(cwds))
|
||||
|
||||
|
||||
def installed_above(cwds: Iterable[str]) -> List[str]:
|
||||
"""Every installed workspace at or above each cwd."""
|
||||
out = []
|
||||
for cwd in cwds:
|
||||
d = _real(cwd) if cwd else ""
|
||||
while d:
|
||||
if _installed(d):
|
||||
out.append(d)
|
||||
if os.path.dirname(d) == d:
|
||||
break
|
||||
d = os.path.dirname(d)
|
||||
return out
|
||||
|
||||
|
||||
def _others(workspace: str, dirs: List[str]) -> List[str]:
|
||||
me = _real(workspace)
|
||||
return [d for i, d in enumerate(dirs) if d != me and d not in dirs[:i]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- monitor
|
||||
|
||||
def monitor_state(workspace: str) -> dict:
|
||||
pids = daemon.find_running(daemon.monitor_script_path(workspace))
|
||||
lock = os.path.join(workspace, ".mam", "monitor.lock")
|
||||
held = False
|
||||
if os.path.exists(lock):
|
||||
with open(lock, "a") as fh:
|
||||
try:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fcntl.flock(fh, fcntl.LOCK_UN)
|
||||
except BlockingIOError:
|
||||
held = True
|
||||
return {"pids": pids, "lock": "held" if held else "free"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- registry backup
|
||||
|
||||
def backup_registry(workspace: str, dest: str) -> List[str]:
|
||||
mam = os.path.join(workspace, ".mam")
|
||||
db = os.path.join(mam, "agent-sessions.db")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
copied = []
|
||||
with open(db + ".lock", "w") as lk:
|
||||
fcntl.flock(lk, fcntl.LOCK_EX)
|
||||
try:
|
||||
if os.path.exists(db):
|
||||
src = sqlite3.connect(db, timeout=60.0)
|
||||
dst = sqlite3.connect(os.path.join(dest, "agent-sessions.db"))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
copied.append("agent-sessions.db")
|
||||
for name in REGISTRY_FILES:
|
||||
p = os.path.join(mam, name)
|
||||
if name != "agent-sessions.db" and os.path.exists(p):
|
||||
shutil.copy2(p, os.path.join(dest, name))
|
||||
copied.append(name)
|
||||
finally:
|
||||
fcntl.flock(lk, fcntl.LOCK_UN)
|
||||
return copied
|
||||
|
||||
|
||||
def _state() -> dict:
|
||||
return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {}
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="lib_py.update")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
s = sub.add_parser("precheck")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--herdr", required=True)
|
||||
s = sub.add_parser("monitor")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s = sub.add_parser("target-workspaces")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--agent-session", default="")
|
||||
s.add_argument("--cwd", default="")
|
||||
s.add_argument("--server", default="default")
|
||||
s.add_argument("--herdr", default="")
|
||||
s = sub.add_parser("backup-registry")
|
||||
s.add_argument("--workspace", required=True)
|
||||
s.add_argument("--dest", required=True)
|
||||
a = p.parse_args(argv)
|
||||
ws = _real(a.workspace)
|
||||
if a.cmd == "precheck":
|
||||
try:
|
||||
state = _state()
|
||||
except ValueError as exc:
|
||||
print("registry: cannot parse MAM_STATE_JSON (%s)" % exc)
|
||||
return 1
|
||||
lines = precheck(ws, state, a.herdr, os.environ)
|
||||
for line in lines:
|
||||
print(line)
|
||||
return 1 if lines else 0
|
||||
if a.cmd == "target-workspaces" and a.cwd:
|
||||
for root in _others(ws, installed_above([a.cwd])):
|
||||
print(root)
|
||||
return 0
|
||||
if a.cmd == "target-workspaces":
|
||||
try:
|
||||
state = _state()
|
||||
except ValueError:
|
||||
state = {}
|
||||
try:
|
||||
roots = target_workspaces(ws, state, a.agent_session, a.server, a.herdr, os.environ)
|
||||
except HerdrError as exc:
|
||||
print("cannot find %s live: %s" % (a.agent_session, exc), file=sys.stderr)
|
||||
return 1
|
||||
for root in roots:
|
||||
print(root)
|
||||
return 0
|
||||
if a.cmd == "monitor":
|
||||
print(json.dumps(monitor_state(ws)))
|
||||
return 0
|
||||
for name in backup_registry(ws, a.dest):
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
@@ -146,12 +146,10 @@ herdr_sessions:
|
||||
cmd: claude
|
||||
cmd_full: claude --dangerously-skip-permissions
|
||||
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_NAME=<herdr_session> herdr agent attach <SESSION_NAME>"
|
||||
kill_command: "HERDR_SESSION_NAME=<herdr_session> herdr kill-session -t <SESSION_NAME>"
|
||||
attach_command: "herdr --session <herdr_session> agent attach <herdr name of 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
|
||||
|
||||
@@ -172,7 +170,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
|
||||
| `--herdr-session` (`--herdr-server`) | Isolated herdr server name |
|
||||
| `--herdr-workspace` | herdr workspace label recorded in the registry (a label only, never a socket) |
|
||||
| `--submit-job PROMPT` | Register a delegated job with this prompt and send it to the new agent |
|
||||
| `--onboard` / `--no-onboard` | Submit the onboarding job (default: on) |
|
||||
| `--onboard` / `--no-onboard` | Submit the onboarding job (default: on); with `--no-onboard` an agent whose id appears only after its first turn (adapter `id_lifecycle`) records none until then, and a same-folder create of its type waits for it (#63, `MAM_ID_ORDER_WAIT_S`) - create warns |
|
||||
| `--auto-trust` / `--no-auto-trust` | Answer a start-up directory-trust dialog or leave it to a human (`MAM_AUTO_TRUST`; default: answer) |
|
||||
| `--isolate` / `--no-isolate` | Legacy, no-op |
|
||||
|
||||
@@ -183,7 +181,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 0 | Created; or `--dry-run` |
|
||||
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
|
||||
| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600), or an update of the workspace is in progress (`multi-agent-mux-update`). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) |
|
||||
| 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded |
|
||||
| 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) |
|
||||
| 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead |
|
||||
@@ -204,15 +202,15 @@ Run from the control root's skills tree (lib.sh's `WORKSPACE_ROOT` = the main ch
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
|
||||
--path ../multi-agent-mux-wt/issue-42 --branch fix/issue-42 [--base <ref>] [--submodules] \
|
||||
[--creator-agent claude [--session <name>] [--role creator] [--herdr-session <s>] [--herdr-workspace <w>] [--onboard]] [--dry-run]
|
||||
[--creator-agent claude [--session <name>] [--role creator] [--herdr-session <s>] [--herdr-workspace <w>] [--no-onboard]] [--dry-run]
|
||||
```
|
||||
|
||||
- Creates the worktree next to the control root (a path **inside** it is refused), idempotently; creates the worktree's **own** `.venv` from the control root's `.venv/bin/python` and installs the delegate-job skill's `requirements.txt` (paho-mqtt, PyYAML - what `deploy/install.sh` installs) plus `requirements.txt` / `requirements-dev.txt` when present (needs network access to the package index; a failed install removes the half-made venv and exits 1 so a re-run starts over; never a link - a leftover link is refused with exit 1; no control-root interpreter is a warning and `VENV=skipped`); adds `/.venv` and `.pytest_cache/` to the shared `info/exclude` (a linked worktree's exclude file is `$GIT_COMMON_DIR/info/exclude`, so it applies to the main checkout too).
|
||||
- **Submodules are opt-in** (`--submodules`). Initialisation writes `submodule.<name>.url` to the shared `.git/config`, and any attempt - even a failed one - leaves a `modules/` directory that makes a plain `git worktree remove` refuse. A failed `--submodules` undoes only what that attempt created - new submodule checkouts are deinitialised before their git dirs are deleted, so the worktree stays a usable checkout; submodules initialised earlier and pre-existing `submodule.*` values are kept - and exits 1; re-run without `--submodules` to finish.
|
||||
- `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree.
|
||||
- `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1` (the row's `pane.cwd` is the worktree) and onboards it (the onboarding job is sent; `--no-onboard` skips it, `--onboard` is the default and still accepted).
|
||||
- Prints `BASE=<sha>`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree <WT> --base "$BASE" --review-only`.
|
||||
- A submodule path that is not initialised yet but already holds files refuses `--submodules` up front (exit 3, nothing changed): the clone would fail on it and a rollback could not tell those files from its own.
|
||||
- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed); 44/45 are create's (onboarding error / timeout with `--onboard`; session and worktree kept).
|
||||
- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed); 44/45 are create's (onboarding error / timeout; onboarding is the default, `--no-onboard` skips it; session and worktree kept).
|
||||
- create itself: a control-plane binding that cannot be computed is refused (exit 2) for a `--workspace` other than the control root and warned about for the control root; the immediate reconcile and the watchdog run the control root's code.
|
||||
|
||||
## Pitfalls
|
||||
@@ -258,5 +256,5 @@ herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = age
|
||||
|
||||
- **Resuming an old conversation** → `multi-agent-mux-resume`
|
||||
- **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
|
||||
@@ -205,6 +205,10 @@ fi
|
||||
|
||||
# Retrieve agent facts once
|
||||
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$AGENT" 2>/dev/null || true)"
|
||||
# Issue #68: warn when --no-onboard is used with a post-discovered-id agent (adapter id_lifecycle via facts).
|
||||
if [ "$ONBOARD" = 0 ] && [ "${MAM_ID_POST_DISCOVERED:-0}" = 1 ]; then
|
||||
echo "WARNING: --no-onboard: $AGENT records its conversation id only after its first turn; until then it is not in the registry, and another $AGENT session created in this folder waits for it (Issue #63, MAM_ID_ORDER_WAIT_S)." >&2
|
||||
fi
|
||||
|
||||
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "$SESSION_UUID" "${USE_WRAPPER:-0}" 2>/dev/null || true)"
|
||||
if [ -z "$CMD_FULL" ]; then
|
||||
@@ -260,7 +264,7 @@ spawn() {
|
||||
SESSION_UUID=""
|
||||
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")"
|
||||
[ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
( mam_update_admit_release_all; exec nohup "$WRAPPER" >/dev/null 2>&1 ) &
|
||||
disown
|
||||
else
|
||||
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
@@ -285,6 +289,10 @@ if [ "$DRY_RUN" = "1" ]; then
|
||||
fi
|
||||
# Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait).
|
||||
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1
|
||||
# Issue #72: no new session while an update of the control root or of an
|
||||
# installed workspace the pane's cwd is in runs; held until the row is written
|
||||
# (lib.sh mam_update_admit_at).
|
||||
mam_update_admit_at "$WORKSPACE" || exit 1
|
||||
HERDR_EPOCH=$(date +%s)
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
spawn
|
||||
@@ -361,7 +369,7 @@ cleanup_herdr_on_error() {
|
||||
if [ "$alive" = "1" ]; then
|
||||
_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 " 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
|
||||
fi
|
||||
_mark_row_rolled_back "terminated" "rolled-back"
|
||||
@@ -428,11 +436,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_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" = "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:
|
||||
@@ -508,12 +511,13 @@ fi
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||
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}" \
|
||||
MAM_WS_LABEL="$MAM_WS_LABEL" \
|
||||
SESSION_UUID="$SESSION_UUID" \
|
||||
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
|
||||
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']
|
||||
agent = os.environ['AGENT']
|
||||
role = os.environ['ROLE']
|
||||
@@ -567,9 +571,7 @@ entry = {
|
||||
'cmd_full': os.environ['CMD_FULL'],
|
||||
'cwd': os.environ['PANE_CWD'],
|
||||
},
|
||||
'start_command': os.environ['START_CMD'],
|
||||
'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}',
|
||||
'attach_command': attach_command(server_name, name),
|
||||
}
|
||||
|
||||
if agent == 'claude':
|
||||
@@ -644,6 +646,7 @@ snap['cwd'] = os.environ['PANE_CWD']
|
||||
print(f"appended: {name}", flush=True)
|
||||
PYEOF
|
||||
ROW_APPENDED=1
|
||||
mam_update_admit_release_all
|
||||
|
||||
echo
|
||||
echo "=== created ==="
|
||||
@@ -773,6 +776,6 @@ fi
|
||||
trap - EXIT
|
||||
echo "agent-sessions.yaml updated"
|
||||
echo
|
||||
echo "Attach: herdr session attach $SESSION_NAME"
|
||||
echo "Attach: $(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")"
|
||||
echo "Delete: use multi-agent-mux-stop skill"
|
||||
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
||||
@@ -6,17 +6,17 @@
|
||||
# Usage:
|
||||
# worktree_prepare.sh --path <WT> --branch <name> [--base <ref>] [--submodules]
|
||||
# [--creator-agent <agent> [--session <name>] [--role <role>]
|
||||
# [--herdr-session <s>] [--herdr-workspace <w>] [--onboard]] [--dry-run]
|
||||
# [--herdr-session <s>] [--herdr-workspace <w>] [--no-onboard]] [--dry-run]
|
||||
#
|
||||
# Steps: git worktree (idempotent) -> the worktree's own .venv (control root
|
||||
# interpreter + requirements*.txt) -> /.venv and .pytest_cache/ in the shared info/exclude -> submodules only with
|
||||
# --submodules (a failed init is rolled back: it would otherwise leave a
|
||||
# modules/ trace that makes a plain `git worktree remove` refuse) -> optional
|
||||
# creator session registered in the control root's .mam -> summary with BASE=.
|
||||
# creator session (onboarded unless --no-onboard) registered in the control root's .mam -> summary with BASE=.
|
||||
#
|
||||
# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule,
|
||||
# 3 refused to protect existing content (nothing changed); 44/45 are create's
|
||||
# (onboarding error / timeout; session and worktree kept, Issue #50).
|
||||
# (onboarding error / timeout - onboarding is the default, --no-onboard skips it; session and worktree kept, Issue #50).
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -27,7 +27,7 @@ usage() {
|
||||
}
|
||||
|
||||
WT=""; BRANCH=""; BASE_REF=""; SUBMODULES=0; DRY_RUN=0
|
||||
CREATOR_AGENT=""; SESSION=""; ROLE="creator"; HS=""; HW=""; ONBOARD=0
|
||||
CREATOR_AGENT=""; SESSION=""; ROLE="creator"; HS=""; HW=""; ONBOARD=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--path) WT="${2:-}"; shift 2 ;;
|
||||
@@ -39,7 +39,8 @@ while [ $# -gt 0 ]; do
|
||||
--role) ROLE="${2:-}"; shift 2 ;;
|
||||
--herdr-session) HS="${2:-}"; shift 2 ;;
|
||||
--herdr-workspace) HW="${2:-}"; shift 2 ;;
|
||||
--onboard) ONBOARD=1; shift ;;
|
||||
--onboard) ONBOARD=1; shift ;; # the default; still accepted
|
||||
--no-onboard) ONBOARD=0; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
|
||||
@@ -301,6 +301,26 @@ cmd_submit() {
|
||||
local reg_dry=()
|
||||
if [[ "$DRY_RUN" == "1" ]]; then reg_dry=(--dry-run); fi
|
||||
|
||||
# Issue #72: no job is registered while an update of this registry's
|
||||
# workspace or of one the target is in (lib_py.update target-workspaces:
|
||||
# its row's, or with no row the live agent's on the server delivery uses)
|
||||
# runs; each is held, fd 7 then 20.., until the record is written (lib.sh
|
||||
# mam_update_admit). A dry-run record is never dispatched and is not
|
||||
# counted by the update.
|
||||
local admit_fds=() target_ws tws fd=20
|
||||
if [[ "$DRY_RUN" != "1" ]]; then
|
||||
target_ws="$(MAM_STATE_JSON="$(load_state_json)" PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.update \
|
||||
target-workspaces --workspace "$WORKSPACE_ROOT" --agent-session "$AGENT_SESSION" \
|
||||
--server "$(_is_remote "$AGENT_SESSION" || resolve_herdr_session "${AGENT_SESSION#herdr:}" "$WORKDIR" 2>/dev/null)" \
|
||||
--herdr "$(_resolve_real_herdr_path || true)")" \
|
||||
|| { echo "ERROR: cannot resolve the workspace of $AGENT_SESSION; nothing was registered" >&2; exit 1; }
|
||||
mam_update_admit "$WORKSPACE_ROOT" || exit 1
|
||||
while IFS= read -r tws; do
|
||||
[[ -n "$tws" ]] || continue
|
||||
mam_update_admit "$tws" "$fd" || exit 1
|
||||
admit_fds+=("$fd"); fd=$((fd + 1))
|
||||
done <<< "$target_ws"
|
||||
fi
|
||||
# 1) register job (prints the new job id)
|
||||
JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \
|
||||
"${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \
|
||||
@@ -310,6 +330,10 @@ cmd_submit() {
|
||||
--job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \
|
||||
--max-iterations "$MAX_ITERATIONS" ${reg_parent[@]+"${reg_parent[@]}"} ${reg_dry[@]+"${reg_dry[@]}"})"
|
||||
echo "registered job: $JOB_ID"
|
||||
if [[ "$DRY_RUN" != "1" ]]; then
|
||||
mam_update_admit_release
|
||||
for fd in ${admit_fds[@]+"${admit_fds[@]}"}; do mam_update_admit_release "$fd"; done
|
||||
fi
|
||||
|
||||
# 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring)
|
||||
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
||||
@@ -895,8 +919,8 @@ finalize_unfinished() {
|
||||
FINAL_JOB_STATUS="$final"
|
||||
if [ "$step" = "probe" ]; then
|
||||
local sess="${target_sess#herdr:}" target_agent diag="no"
|
||||
target_agent=$(_sanitize_herdr_agent_name "$sess")
|
||||
_within_deadline "$deadline" _agent_still_working "$sess" "$target_agent" && diag="yes"
|
||||
target_agent=$(_sanitize_herdr_agent_name "$sess") || target_agent=""
|
||||
[ -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
|
||||
fi
|
||||
# The answer comes back on fd 3: stdout carries registry.py's close output.
|
||||
@@ -1018,7 +1042,7 @@ inject_agent() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $(session_attach_command "$sess" "${HERDR_SESSION_NAME:-default}"))"
|
||||
trap - EXIT
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -244,6 +244,9 @@ log_error() {
|
||||
echo -e "\033[1;31m[✗]\033[0m $1"
|
||||
}
|
||||
|
||||
# Issue #72: no loop starts while an update runs; held until the loop marker
|
||||
# is published (lib.sh mam_update_admit), released before the loop body runs.
|
||||
mam_update_admit "$MAM_REAL_ROOT" || exit 1
|
||||
_mam_acquire_rc=0
|
||||
mam_acquire_loop_lock "$MAM_LOOP_MARKER" || _mam_acquire_rc=$?
|
||||
case "$_mam_acquire_rc" in
|
||||
@@ -262,6 +265,7 @@ case "$_mam_acquire_rc" in
|
||||
;;
|
||||
esac
|
||||
trap _mam_release_guard EXIT INT TERM HUP
|
||||
mam_update_admit_release
|
||||
rm -f "$MAM_REAL_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true
|
||||
|
||||
# --all-reviewer silently takes precedence over an explicit --reviewer list;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
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}")
|
||||
|
||||
|
||||
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_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', '')
|
||||
if srv == 'default' and _ambient and _ambient != 'default':
|
||||
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
|
||||
# only if that failed: 'now' can merely over-estimate creation time,
|
||||
# which tightens the stale-transcript guard, whereas 0 disables it and
|
||||
@@ -674,9 +673,7 @@ if herdr_confirmed:
|
||||
'herdr_server': srv,
|
||||
'herdr_workspace': _slug(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': f'HERDR_SESSION_NAME={srv} herdr agent attach {name}',
|
||||
'kill_command': f'herdr {server_opt}kill-session -t {name}',
|
||||
'attach_command': attach_command(srv, name),
|
||||
'last_visible_status': 'running',
|
||||
'last_visible_note': 'auto-registered by monitor',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -257,6 +257,11 @@ if [ "$DRY_RUN" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Issue #72 (U2): no registry write while an update runs - a rollback would
|
||||
# restore the registry without it. Held until the write is done (lib.sh
|
||||
# mam_update_admit).
|
||||
mam_update_admit || exit 1
|
||||
|
||||
# Issue #13: the orchestrator has no herdr_sessions row, so stop_session.sh
|
||||
# --purge-conversation can never reach its conversation. Purge FIRST, through
|
||||
# the adapter (`lib_py.agents purge`): a refusal because the conversation is
|
||||
@@ -304,6 +309,7 @@ elif op == "remove":
|
||||
uuids = [u for u in uuids if u != target]
|
||||
d["orchestrator_uuids"] = uuids
|
||||
PYEOF
|
||||
mam_update_admit_release
|
||||
|
||||
case "$MODE" in
|
||||
add) verb="added" ;;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: multi-agent-mux-reset
|
||||
description: "Purge the control root's running resident agents and recreate each with the same name, agent, role, herdr server and workspace label, one at a time, with onboarding. Persists .mam/reset-spec.json before the first purge so an interrupted or skipped run resumes where it left off."
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, herdr]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [reset, purge, recreate, lifecycle, onboarding]
|
||||
related_skills: [multi-agent-mux-stop, multi-agent-mux-create, multi-agent-mux-status]
|
||||
---
|
||||
|
||||
# Multi-Agent Reset
|
||||
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh --yes [--dry-run]
|
||||
```
|
||||
|
||||
| Item | Behaviour |
|
||||
|---|---|
|
||||
| Targets | Every registry row with `status: running` and `realpath(pane.cwd) == realpath(WS)`. Worktree rows, `stopped`/`terminated` rows, and `orchestrator_uuids` are skipped and listed with their reason. |
|
||||
| Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required (exits `3` otherwise). `--dry-run` inspects without `--yes` and changes nothing. |
|
||||
| Prechecks | Refuses before any change when an open job exists (prints the `registry.py ... close --request cancelled` command), when `.mam/loop-guard-active` or `.mam/update-in-progress` is held, when a target row lacks a valid agent or role, or when the caller itself is one of the targets. |
|
||||
| Lock & concurrency | Takes update admission (`mam_update_admit`), acquires `.mam/loop-guard-active` (`mam_acquire_loop_lock`), then releases update admission so `run_loop.sh`, `update.sh`, and concurrent resets refuse while the reset runs. |
|
||||
| Spec file & rerun | Writes `.mam/reset-spec.json` atomically before the first stop/purge and removes it only when all targets reach `done`. Rerunning resumes from the spec file without re-purging already recreated agents (`created` or `done`). |
|
||||
| Sequence per target | Sequential (never parallel): ① normal `stop_session.sh --reason reset` (captures verified ID onto the row while herdr is live and sets `status: stopped`), ② checks that the row has a recorded conversation ID, ③ `stop_session.sh --reason reset --purge-conversation --yes` (using the row's recorded ID, never a CLI UUID), then `create_session.sh` with the original name, agent, role, `--herdr-session`, and `--herdr-workspace` (onboarding enabled by default). |
|
||||
| When skipped (exit 4) | If step ② finds no recorded ID or step ③ exits `4` (in-use gate), the target is skipped and the reset continues to the next agent: the skipped agent is stopped but resumable (its row keeps its id) and its conversation files are untouched; rerun once its holders end, or resume it. |
|
||||
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 0 | All targeted agents reached `done` and `.mam/reset-spec.json` was removed; or nothing to reset, cancelled at prompt, or `--dry-run` with no blockers |
|
||||
| 1 | Refused by precheck (nothing changed), corrupt/mismatched `.mam/reset-spec.json`, or a `stop`/`create` step failed (`.mam/reset-spec.json` is kept so rerunning resumes) |
|
||||
| 2 | Invalid arguments |
|
||||
| 3 | Not a terminal and `-y`/`--yes` was not passed; nothing was changed |
|
||||
| 4 | Completed the pass, but one or more agents were skipped (purge refused with stop exit 4, or no conversation ID recorded); `.mam/reset-spec.json` is kept for rerun |
|
||||
| 5 | No agent was skipped or failed, but one or more newly created post-discovered agents do not have their new conversation ID recorded yet; `.mam/reset-spec.json` is kept so rerunning re-checks without re-purging |
|
||||
| other | Under `set -e`, an unexpected sub-step failure passes its nonzero exit code through |
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- **Irreversible**: purged conversations cannot be recovered. Confirm with `--dry-run` first.
|
||||
- **No external process termination**: processes holding conversation files open (reported via `lsof` in `in_use_hints`) are never signalled by MAM; end them yourself when safe, then rerun.
|
||||
- **Shared stores preserved**: Grok's shared `memory-v2.db` is left intact during purge; only session rows and per-session files are removed.
|
||||
- **Orchestrator hook during reset**: while `.mam/loop-guard-active` is held by the reset, the AGY orchestrator's direct file-edit hook (`loop_delegation_guard.sh`) blocks direct edits until the reset finishes.
|
||||
- **Post-precheck job submissions**: `multi-agent-mux-delegate-job submit` does not check `.mam/loop-guard-active`; do not submit new jobs to resident agents while a reset is running.
|
||||
- **Same-type post-discovered ordering**: when resetting multiple post-discovered agents of the same type, the second `create_session.sh` waits up to `MAM_ID_ORDER_WAIT_S` for the first agent's ID to be recorded; if it times out, rerun the reset once the ID is recorded.
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env bash
|
||||
# reset.sh — multi-agent-mux-reset (Issue #78): purge the control root's
|
||||
# running resident agents and recreate each with the same name, agent, role,
|
||||
# herdr server and workspace label, one at a time, with onboarding.
|
||||
# Usage:
|
||||
# bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh [-y|--yes] [--dry-run]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)"
|
||||
export WORKSPACE_ROOT="$WS"
|
||||
export AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WS/.mam/agent-sessions.yaml}"
|
||||
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh [-y|--yes] [--dry-run]
|
||||
|
||||
Options:
|
||||
-y, --yes Confirm purging and recreating the control root's running agents
|
||||
--dry-run Inspect targets, skipped rows, spec progress and blockers without changing anything
|
||||
-h, --help Show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
YES=0
|
||||
DRY_RUN=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y|--yes) YES=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$DRY_RUN" != "1" ] && [ "$YES" != "1" ] && [ ! -t 0 ]; then
|
||||
echo "ERROR: not a terminal; run with -y/--yes (or --dry-run to inspect)" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
MAM="$WS/.mam"
|
||||
MARKER="${MAM_LOOP_MARKER:-$MAM/loop-guard-active}"
|
||||
SPEC_FILE="$MAM/reset-spec.json"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
if [ -x "$WS/.venv/bin/python" ]; then
|
||||
PY="$WS/.venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PY="python3"
|
||||
else
|
||||
PY="python"
|
||||
fi
|
||||
DRY_ARGS=()
|
||||
if [ -f "$MAM/update-in-progress" ]; then
|
||||
upd_st="$(_mam_lock_holder_state "$MAM/update-in-progress")"
|
||||
case "$upd_st" in
|
||||
stale) echo "NOTE: stale update marker $MAM/update-in-progress (its update is gone); not a reason to refuse" ;;
|
||||
*) DRY_ARGS+=(--refusal "an update of $WS is in progress (pid $(_mam_lock_field "$MAM/update-in-progress" pid), holder $upd_st); wait for it to finish") ;;
|
||||
esac
|
||||
fi
|
||||
if [ -f "$MARKER" ]; then
|
||||
loop_st="$(_mam_lock_holder_state "$MARKER")"
|
||||
case "$loop_st" in
|
||||
stale) echo "NOTE: stale loop marker $MARKER (its loop is gone); not a reason to refuse" ;;
|
||||
*) DRY_ARGS+=(--refusal "a review loop or another reset is running (pid $(_mam_lock_field "$MARKER" pid), holder $loop_st); wait for it to end") ;;
|
||||
esac
|
||||
fi
|
||||
dry_rc=0
|
||||
"$PY" -m lib_py.reset dry-run --workspace "$WS" ${DRY_ARGS[@]+"${DRY_ARGS[@]}"} || dry_rc=$?
|
||||
if [ "$dry_rc" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$WS/.agents/skills/lib.sh"
|
||||
PY="$(_delegate_py_bin)"
|
||||
|
||||
mam_update_admit "$WS" || exit 1
|
||||
_acquire_rc=0
|
||||
mam_acquire_loop_lock "$MARKER" || _acquire_rc=$?
|
||||
case "$_acquire_rc" in
|
||||
0) ;;
|
||||
3)
|
||||
mam_update_admit_release
|
||||
_holder_pid="$(_mam_lock_field "$MARKER" "pid" 2>/dev/null || echo "unknown")"
|
||||
echo "ERROR: reset refused; nothing was changed:" >&2
|
||||
echo " - a review loop or another reset is running (pid $_holder_pid); wait for it to end" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
mam_update_admit_release
|
||||
echo "ERROR: reset refused; nothing was changed:" >&2
|
||||
echo " - cannot determine whether a review loop or another reset is running (ps unavailable?)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
_release_reset_lock() {
|
||||
mam_release_loop_lock "$MARKER" || true
|
||||
}
|
||||
_on_reset_signal() {
|
||||
local sig_rc="$1"
|
||||
_release_reset_lock
|
||||
exit "$sig_rc"
|
||||
}
|
||||
trap _release_reset_lock EXIT
|
||||
trap '_on_reset_signal 130' INT
|
||||
trap '_on_reset_signal 143' TERM
|
||||
trap '_on_reset_signal 129' HUP
|
||||
mam_update_admit_release
|
||||
|
||||
STATE_JSON=""
|
||||
if ! STATE_JSON="$(load_state_json strict)"; then
|
||||
echo "ERROR: reset refused; nothing was changed:" >&2
|
||||
echo " - the registry $AGENT_SESSIONS_YAML cannot be read; fix it first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre_out=""
|
||||
pre_rc=0
|
||||
pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset precheck --workspace "$WS")" || pre_rc=$?
|
||||
if [ "$pre_rc" -ne 0 ]; then
|
||||
echo "ERROR: reset refused; nothing was changed:" >&2
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && echo " - $line" >&2
|
||||
done <<< "${pre_out:-lib_py.reset precheck failed (rc $pre_rc)}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$SPEC_FILE" ]; then
|
||||
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1
|
||||
if [ "$YES" != "1" ]; then
|
||||
ans=""
|
||||
read -r -p "Resume reset from $SPEC_FILE? [y/N]: " ans || ans=""
|
||||
case "$ans" in
|
||||
y|Y|yes|Yes|YES) ;;
|
||||
*) echo "Reset cancelled; nothing was changed"; exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
PLAN_JSON="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset plan --workspace "$WS")" || exit 1
|
||||
TARGET_COUNT="$(printf '%s' "$PLAN_JSON" | "$PY" -c 'import json,sys; print(len(json.load(sys.stdin).get("targets", [])))')"
|
||||
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset dry-run --workspace "$WS" || exit 1
|
||||
if [ "$TARGET_COUNT" = "0" ]; then
|
||||
echo "nothing to reset"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$YES" != "1" ]; then
|
||||
ans=""
|
||||
read -r -p "Purge and recreate $TARGET_COUNT agents? [y/N]: " ans || ans=""
|
||||
case "$ans" in
|
||||
y|Y|yes|Yes|YES) ;;
|
||||
*) echo "Reset cancelled; nothing was changed"; exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
printf '%s' "$PLAN_JSON" | "$PY" -m lib_py.reset write-spec --workspace "$WS" || exit 1
|
||||
fi
|
||||
|
||||
ENTRIES_OUT="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --warn-new)" || exit 1
|
||||
ENTRY_NAMES=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
eval "$line"
|
||||
ENTRY_NAMES+=("$R_NAME")
|
||||
done <<< "$ENTRIES_OUT"
|
||||
|
||||
SKIPPED_ARGS=()
|
||||
FAILED_SPEC=""
|
||||
|
||||
for target_name in ${ENTRY_NAMES[@]+"${ENTRY_NAMES[@]}"}; do
|
||||
if ! STATE_JSON="$(load_state_json strict)"; then
|
||||
FAILED_SPEC="$target_name:stop:1"
|
||||
break
|
||||
fi
|
||||
entry_line="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --session "$target_name")" || {
|
||||
FAILED_SPEC="$target_name:stop:1"
|
||||
break
|
||||
}
|
||||
eval "$entry_line"
|
||||
|
||||
case "$R_STATE" in
|
||||
done|created)
|
||||
continue
|
||||
;;
|
||||
pending)
|
||||
STOP_CMD=(bash "$SKILL_DIR/multi-agent-mux-stop/scripts/stop_session.sh" --session "$R_NAME" --agent "$R_AGENT")
|
||||
if [ -n "$R_SERVER" ]; then
|
||||
STOP_CMD+=(--herdr-session "$R_SERVER")
|
||||
fi
|
||||
STOP_CMD+=(--reason reset)
|
||||
|
||||
echo "reset [$R_NAME]: ${STOP_CMD[*]}"
|
||||
stop_rc=0
|
||||
"${STOP_CMD[@]}" || stop_rc=$?
|
||||
if [ "$stop_rc" -ne 0 ]; then
|
||||
FAILED_SPEC="$R_NAME:stop:$stop_rc"
|
||||
break
|
||||
fi
|
||||
|
||||
if ! STATE_JSON="$(load_state_json strict)"; then
|
||||
FAILED_SPEC="$R_NAME:stop:1"
|
||||
break
|
||||
fi
|
||||
if [ -z "$R_OLD_ID" ]; then
|
||||
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset record-id --workspace "$WS" --session "$R_NAME" || {
|
||||
FAILED_SPEC="$R_NAME:stop:1"
|
||||
break
|
||||
}
|
||||
fi
|
||||
entry_line="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset entries --workspace "$WS" --session "$R_NAME")" || {
|
||||
FAILED_SPEC="$R_NAME:stop:1"
|
||||
break
|
||||
}
|
||||
eval "$entry_line"
|
||||
if [ -z "$R_ROW_ID" ]; then
|
||||
echo "skipped $R_NAME: no conversation id is recorded, so a purge would drop the row and leave the conversation on disk; resume it, or purge it by hand (stop_session.sh --session $R_NAME --purge-conversation --yes) and rerun the reset to recreate it"
|
||||
SKIPPED_ARGS+=(--skipped "$R_NAME:no_id")
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "reset [$R_NAME]: ${STOP_CMD[*]} --purge-conversation --yes"
|
||||
purge_rc=0
|
||||
"${STOP_CMD[@]}" --purge-conversation --yes || purge_rc=$?
|
||||
if [ "$purge_rc" -eq 4 ]; then
|
||||
echo "skipped $R_NAME: its purge was refused (stop exit 4); the row (now stopped, id recorded) and the conversation are kept; the processes above hold it - they are not killed; rerun the reset once they end"
|
||||
SKIPPED_ARGS+=(--skipped "$R_NAME:purge_refused")
|
||||
continue
|
||||
fi
|
||||
if [ "$purge_rc" -ne 0 ]; then
|
||||
FAILED_SPEC="$R_NAME:stop:$purge_rc"
|
||||
break
|
||||
fi
|
||||
R_STATE="purged"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$R_STATE" = "purged" ]; then
|
||||
CREATE_CMD=(bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" --workspace "$WS" --agent "$R_AGENT" --role "$R_ROLE" --session "$R_NAME")
|
||||
if [ -n "$R_SERVER" ]; then
|
||||
CREATE_CMD+=(--herdr-session "$R_SERVER")
|
||||
fi
|
||||
if [ -n "$R_LABEL" ]; then
|
||||
CREATE_CMD+=(--herdr-workspace "$R_LABEL")
|
||||
fi
|
||||
|
||||
echo "reset [$R_NAME]: ${CREATE_CMD[*]}"
|
||||
create_rc=0
|
||||
"${CREATE_CMD[@]}" || create_rc=$?
|
||||
if [ "$create_rc" -ne 0 ]; then
|
||||
FAILED_SPEC="$R_NAME:create:$create_rc"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
STATE_JSON="$(load_state_json strict)" || exit 1
|
||||
TABLE_ARGS=(--workspace "$WS")
|
||||
if [ ${#SKIPPED_ARGS[@]} -gt 0 ]; then
|
||||
TABLE_ARGS+=("${SKIPPED_ARGS[@]}")
|
||||
fi
|
||||
if [ -n "$FAILED_SPEC" ]; then
|
||||
TABLE_ARGS+=(--failed "$FAILED_SPEC")
|
||||
fi
|
||||
table_rc=0
|
||||
MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.reset table "${TABLE_ARGS[@]}" || table_rc=$?
|
||||
case "$table_rc" in
|
||||
0)
|
||||
rm -f "$SPEC_FILE"
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
exit 4
|
||||
;;
|
||||
5)
|
||||
exit 5
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
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>`.
|
||||
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`)
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
`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`), then the control root's monitor daemon (`start_watchdog`, as create; not with `MAM_NO_SUBSCRIBER_DAEMON=1`). 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 |
|
||||
|---|---|
|
||||
@@ -75,11 +75,11 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` |
|
||||
| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), or agent binary missing/not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) |
|
||||
| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), agent binary missing/not executable, or an update of the workspace is in progress (`multi-agent-mux-update`). **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) |
|
||||
| 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) |
|
||||
| other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back |
|
||||
|
||||
A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-243).
|
||||
A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-259).
|
||||
|
||||
## Original cwd only (Issue #21 phase 2)
|
||||
|
||||
@@ -136,4 +136,4 @@ herdr --session "$SERVER" agent read "$SESSION_NAME" --source visible --lines 30
|
||||
|
||||
- **No saved session yet** → `multi-agent-mux-create`
|
||||
- **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)
|
||||
@@ -154,18 +154,34 @@ if [ -z "$UUID" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# Issue #72: a resumed session gets the monitor daemon create starts - the
|
||||
# control root's (Issue #21 A8), also for a worktree --workspace. Called after
|
||||
# the row is written; MAM_NO_SUBSCRIBER_DAEMON=1 starts none. Never fails the resume.
|
||||
_ensure_monitor_daemon() {
|
||||
local wd_pid
|
||||
if wd_pid=$(start_watchdog "resume" "$WORKSPACE_ROOT"); then
|
||||
echo "watchdog PID: $wd_pid"
|
||||
else
|
||||
echo "WARNING: could not start the monitor daemon of '$WORKSPACE_ROOT'; the session itself was resumed" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# 2. If herdr is alive, print warning or attach.
|
||||
if [ "$HERDR_ALIVE" = "1" ]; then
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||
echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate"
|
||||
exit 0
|
||||
fi
|
||||
# Issue #72: no registry write while an update runs (lib.sh mam_update_admit).
|
||||
mam_update_admit || exit 1
|
||||
echo "herdr '$SESSION_NAME' already running."
|
||||
# Just update YAML to make sure it's set to running
|
||||
CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
||||
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
|
||||
--herdr-session "$HERDR_SESSION_NAME" --already-running \
|
||||
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
|
||||
mam_update_admit_release
|
||||
_ensure_monitor_daemon
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -238,6 +254,11 @@ if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Issue #72: no new session while an update of the control root or of an
|
||||
# installed workspace the pane's cwd is in runs; held until the row is written
|
||||
# (lib.sh mam_update_admit_at).
|
||||
mam_update_admit_at "$WORKSPACE" || exit 1
|
||||
|
||||
# Issue #63: wait if another same-cwd session of this post-discovered agent
|
||||
# is still being created or has not recorded its id yet (lib.sh mam_id_order_wait).
|
||||
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" "$UUID" || exit 1
|
||||
@@ -274,5 +295,7 @@ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_r
|
||||
--herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \
|
||||
${DROP_FLAG:+$DROP_FLAG} \
|
||||
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
|
||||
mam_update_admit_release_all
|
||||
_ensure_monitor_daemon
|
||||
|
||||
echo "Successfully resumed $SESSION_NAME ($AGENT)"
|
||||
@@ -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}" \
|
||||
ALREADY_RUNNING="$ALREADY_RUNNING" DROP_STALE_ID="$DROP_STALE_ID" \
|
||||
CMD_FULL_OPT="$CMD_FULL_OPT" <<'PYEOF'
|
||||
from lib_py.agents.sanitize import attach_command
|
||||
name = os.environ['SESSION_NAME']
|
||||
uuid = os.environ['UUID']
|
||||
agent = os.environ['AGENT']
|
||||
@@ -147,9 +148,7 @@ if target is None:
|
||||
'herdr_workspace': wsl,
|
||||
'delegate_job_id': None,
|
||||
'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': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
||||
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
|
||||
'attach_command': attach_command(server_name, name),
|
||||
}
|
||||
d.setdefault('herdr_sessions', []).append(target)
|
||||
else:
|
||||
@@ -159,9 +158,7 @@ else:
|
||||
if is_explicit or not target.get('herdr_session'):
|
||||
target['herdr_session'] = sn
|
||||
target['herdr_server'] = sn
|
||||
target['start_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {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}'
|
||||
target['attach_command'] = attach_command(sn, name)
|
||||
wsl = os.environ.get('MAM_WS_LABEL', '')
|
||||
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
|
||||
if wsl and (ws_explicit or not target.get('herdr_workspace')):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
@@ -94,11 +94,10 @@ lab-paper-pdf2md-creator-claude default running alive clau
|
||||
"job_id": null,
|
||||
"job_status": null,
|
||||
"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": [],
|
||||
"pane_pid": 12345,
|
||||
"cmd_full": "claude --dangerously-skip-permissions --session-id <uuid>",
|
||||
"start_command": "...",
|
||||
"last_visible_status": "pinned"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -221,11 +221,10 @@ for s in d.get('herdr_sessions', []):
|
||||
'pane_cwd': pane.get('cwd'),
|
||||
'attach_command': s.get('attach_command'),
|
||||
'drift_classes': drift_by_name.get(name, []),
|
||||
# Additive beyond the D8 example — needed by the M1 Detail Pane
|
||||
# (pane pid / full launch command / start command / last banner text).
|
||||
# Additive beyond the D8 example (pane pid / full launch command /
|
||||
# last banner text).
|
||||
'pane_pid': pane.get('pid'),
|
||||
'cmd_full': pane.get('cmd_full'),
|
||||
'start_command': s.get('start_command'),
|
||||
'last_visible_status': s.get('last_visible_status'),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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."
|
||||
version: 4.10.0
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
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 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**:
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
@@ -59,10 +58,12 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
||||
| Exit | Meaning |
|
||||
|---|---|
|
||||
| 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, the session is neither registered nor on the herdr server, or an update of the workspace is in progress (`multi-agent-mux-update`). **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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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 유무로 대체되었습니다.
|
||||
#
|
||||
# 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 누락)
|
||||
# 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
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
@@ -97,12 +100,18 @@ fi
|
||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
# Issue #72 (U2): no registry write while an update runs - a rollback would
|
||||
# restore the registry without it. Held until the row is written (lib.sh
|
||||
# mam_update_admit).
|
||||
mam_update_admit || exit 1
|
||||
|
||||
# Implement the purging-<session> file lock mechanism
|
||||
if [ "$PURGE" = "1" ]; then
|
||||
touch "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"
|
||||
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
|
||||
fi
|
||||
|
||||
AMBIENT_HERDR_SESSION="${HERDR_SESSION_NAME:-}"
|
||||
if [ -n "$HERDR_SERVER_OPT" ]; then
|
||||
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
||||
else
|
||||
@@ -110,20 +119,64 @@ else
|
||||
export HERDR_SESSION_NAME
|
||||
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
|
||||
# Issue #71: a session with no registry row (its create/resume died before
|
||||
# registering it) is closed here - herdr only, the registry is left alone. It
|
||||
# has no agent kind, so --agent is not needed. Only a session herdr runs
|
||||
# inside this workspace is closed; anything else changes nothing. With no row
|
||||
# the server is chosen as create_session.sh chose it: --herdr-session, else
|
||||
# the environment's HERDR_SESSION_NAME, else the workspace's server.
|
||||
stop_rowless() {
|
||||
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
|
||||
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 추출.
|
||||
# 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
|
||||
name = os.environ['SESSION_NAME']
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
@@ -136,10 +189,24 @@ for s in d.get('herdr_sessions', []):
|
||||
'delegate_job_logs_dir': cp.get('delegate_job_logs_dir', '') or ''}))
|
||||
sys.exit(0)
|
||||
sys.exit(7)
|
||||
") || {
|
||||
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
|
||||
") || MAPPED_RC=$?
|
||||
# 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
|
||||
}
|
||||
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",""))')
|
||||
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
|
||||
@@ -270,7 +337,7 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
|
||||
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
echo "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2
|
||||
echo " Refusing registry removal — records preserved (no state was modified)." >&2
|
||||
echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2
|
||||
echo " Diagnose the stuck TUI ($(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")), then re-run" >&2
|
||||
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
exit 4
|
||||
@@ -391,6 +458,7 @@ if purge:
|
||||
else:
|
||||
print(f"updated: {name} status={target['status']}", flush=True)
|
||||
PYEOF
|
||||
mam_update_admit_release
|
||||
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "session stopped by operator before job completion" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: multi-agent-mux-update
|
||||
description: "Update an installed MAM workspace in place to the latest version. Refuses while a session, an open job, a review loop or an unknown monitor is live; stops the monitor daemon and starts it again afterwards; rolls everything back if the install fails; converts the registry with the newly installed code. Use instead of the removed .mam_deploy/update.sh."
|
||||
version: 4.13.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, herdr]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [update, install, registry, migration]
|
||||
related_skills: [multi-agent-mux-stop, multi-agent-mux-resume, multi-agent-mux-monitor]
|
||||
---
|
||||
|
||||
# Multi-Agent Update
|
||||
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-update/scripts/update.sh --yes
|
||||
```
|
||||
|
||||
| Item | Behaviour |
|
||||
|---|---|
|
||||
| Where | An installed workspace only (`.mam_deploy/remove.sh` present). A git checkout updates with `git pull`. |
|
||||
| Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required. There is no force option. |
|
||||
| Refused when | a running registry row, a live herdr agent whose cwd is in the workspace (any running server), an open job (an unreadable job JSON or jobs directory included, `dry_run` excluded), a live loop marker, `.mam/monitor.lock` held by a monitor the skills did not start, or another update. The calling agent (its herdr server + pane, row and one running job) is not counted. Each refusal lists the cleanup; nothing is changed. |
|
||||
| During the update | create, resume, `run_loop.sh`, delegate submit, stop and orc-onboard refuse (`.mam/update-in-progress`, `.mam/update-admission.lock`). They refuse during the update's checks too, even when the update is then refused; run them again afterwards. create and resume also take the lock of every other installed workspace at or above the session's `--workspace`. |
|
||||
| Monitor | stopped first, started again afterwards only if it ran (`MAM_NO_SUBSCRIBER_DAEMON=1`: not started). |
|
||||
| Install | the installer at `MAM_INSTALLER_URL` (default: Gitea `main`) runs in place; `.mam` is kept. On failure `.agents/`, `.mam_deploy/`, root files, `.venv`, the installer's `.mam` files and the registry are restored from `.mam/update-backup/`. |
|
||||
| Registry | converted by the new code (`lib_py.registry_format`); a failed conversion keeps the install and exits nonzero. |
|
||||
| Exit | `0` updated (or cancelled at the prompt), `1` refused or failed, `2` bad arguments; a failed registry conversion after a good install exits with the converter's nonzero code. |
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_phase.sh — multi-agent-mux-update, the part that changes the tree (Issue #72, decision 5).
|
||||
#
|
||||
# install_phase.sh run <workspace> <monitor_was_running 0|1> <tmpdir>
|
||||
# install_phase.sh restore <workspace> [--skip-registry]
|
||||
#
|
||||
# update.sh copies this file outside the workspace and execs the copy (same
|
||||
# pid and start time, so its .mam/update-in-progress marker stays live). From
|
||||
# here on the tree is being replaced, so this calls no lib.sh or lib_py: it runs
|
||||
# the installer in place, and on failure restores .mam/update-backup/ and starts
|
||||
# the monitor again with the restored (old, whole) tree's start_watchdog. On
|
||||
# success phase 2 is the NEW tree's post_update.sh, in a child process. It ends
|
||||
# by removing the backup and the marker (only its own: pid + lstart).
|
||||
# Must run on macOS bash 3.2.
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${1:-}"
|
||||
WS="${2:-}"
|
||||
[ -n "$MODE" ] && [ -d "$WS" ] || { echo "usage: $0 run|restore <workspace> ..." >&2; exit 2; }
|
||||
BK="$WS/.mam/update-backup"
|
||||
|
||||
set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; }
|
||||
|
||||
# Puts back every backed-up item, removes the ones that did not exist before,
|
||||
# then (unless --skip-registry) the registry: the DB from the backup, its
|
||||
# -wal/-shm removed (macOS keeps them after close).
|
||||
restore() {
|
||||
local rel n
|
||||
[ -f "$BK/items" ] || { echo "ERROR: $BK/items is missing" >&2; return 1; }
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
rm -rf "${WS:?}/$rel" || return 1
|
||||
grep -Fqx -- "$rel" "$BK/absent" 2>/dev/null && continue
|
||||
mkdir -p "$(dirname "$WS/$rel")" && cp -pPR "$BK/files/$rel" "$WS/$rel" || return 1
|
||||
done < "$BK/items"
|
||||
[ "${1:-}" = "--skip-registry" ] && return 0
|
||||
for n in agent-sessions.yaml agent-sessions.yaml.bak agent-sessions.db; do
|
||||
rm -f "$WS/.mam/$n" || return 1
|
||||
if [ -f "$BK/registry/$n" ]; then
|
||||
cp -p "$BK/registry/$n" "$WS/.mam/$n" || return 1
|
||||
fi
|
||||
done
|
||||
rm -f "$WS/.mam/agent-sessions.db-wal" "$WS/.mam/agent-sessions.db-shm"
|
||||
}
|
||||
|
||||
release_marker() {
|
||||
local m="$WS/.mam/update-in-progress" pid lstart cur
|
||||
[ -f "$m" ] || return 0
|
||||
pid="$(grep '^pid=' "$m" | head -n 1 | cut -d= -f2-)"
|
||||
lstart="$(grep '^lstart=' "$m" | head -n 1 | cut -d= -f2-)"
|
||||
cur="$(ps -p $$ -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//')"
|
||||
if [ "$pid" = "$$" ] && [ "$lstart" = "$cur" ]; then
|
||||
rm -f "$m"
|
||||
fi
|
||||
}
|
||||
|
||||
restart_old_monitor() {
|
||||
[ "$1" = "1" ] || return 0
|
||||
if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
|
||||
echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)"
|
||||
return 0
|
||||
fi
|
||||
local pid
|
||||
if pid="$(bash -c 'source "$1/.agents/skills/lib.sh" && start_watchdog update "$1"' _ "$WS")"; then
|
||||
echo "monitor: restarted (PID $pid)"
|
||||
else
|
||||
echo "ERROR: could not restart the monitor daemon" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
restore)
|
||||
restore "${3:-}"
|
||||
exit $?
|
||||
;;
|
||||
run) ;;
|
||||
*) echo "usage: $0 run|restore <workspace> ..." >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
MON="${3:-0}"
|
||||
TMPD="${4:-}"
|
||||
trap '[ -z "$TMPD" ] || rm -rf "$TMPD"' EXIT
|
||||
|
||||
if ! set_state installing; then
|
||||
# Nothing was installed yet: drop the backup and put the monitor back as phase 1 found it.
|
||||
echo "ERROR: cannot write $BK/state; nothing was changed" >&2
|
||||
rm -rf "$BK"
|
||||
restart_old_monitor "$MON"
|
||||
release_marker
|
||||
exit 1
|
||||
fi
|
||||
URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}"
|
||||
echo "install: $URL"
|
||||
irc=0
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$URL" | bash -s -- "$WS" || irc=$?
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$URL" | bash -s -- "$WS" || irc=$?
|
||||
else
|
||||
echo "ERROR: neither curl nor wget is available to fetch the installer" >&2
|
||||
irc=1
|
||||
fi
|
||||
|
||||
if [ "$irc" -ne 0 ]; then
|
||||
echo "ERROR: the install failed (rc $irc); rolling back" >&2
|
||||
if restore; then
|
||||
rm -rf "$BK"
|
||||
echo "rolled back: the workspace is at the version it had before the update"
|
||||
restart_old_monitor "$MON"
|
||||
else
|
||||
echo "ERROR: the rollback failed; the backup is kept at $BK (the next update restores it)" >&2
|
||||
fi
|
||||
release_marker
|
||||
exit 1
|
||||
fi
|
||||
set_state installed
|
||||
|
||||
POST="$WS/.agents/skills/multi-agent-mux-update/scripts/post_update.sh"
|
||||
prc=0
|
||||
if [ -f "$POST" ]; then
|
||||
bash "$POST" --monitor "$MON" || prc=$?
|
||||
else
|
||||
echo "ERROR: the installed version has no $POST; the registry converts on its next write and the monitor was not restarted" >&2
|
||||
prc=1
|
||||
fi
|
||||
rm -rf "$BK"
|
||||
release_marker
|
||||
[ "$prc" -eq 0 ] && echo "Update complete."
|
||||
exit "$prc"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# post_update.sh — multi-agent-mux-update phase 2 (Issue #72, decision 5).
|
||||
# post_update.sh --monitor <0|1>
|
||||
#
|
||||
# Run by install_phase.sh from the NEWLY installed tree, in its own process:
|
||||
# converts the registry with the new code's entry point (lib_py.registry_format,
|
||||
# #71) and starts the monitor again if it ran before the update. Exit 0, or
|
||||
# nonzero when the conversion or the monitor restart failed (the install is
|
||||
# kept either way: the registry converts again on its next write).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)"
|
||||
export WORKSPACE_ROOT="$WS"
|
||||
export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml"
|
||||
# shellcheck disable=SC1091
|
||||
source "$WS/.agents/skills/lib.sh"
|
||||
|
||||
MON=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--monitor) MON="$2"; shift 2 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
rc=0
|
||||
if [ ! -f "$AGENT_SESSIONS_YAML" ] && [ ! -f "$WS/.mam/agent-sessions.db" ]; then
|
||||
echo "registry: nothing to convert (no registry)"
|
||||
else
|
||||
"$(_delegate_py_bin)" -m lib_py.registry_format --yaml "$AGENT_SESSIONS_YAML" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "ERROR: the registry was not converted (rc $rc); the install is kept and the next registry write converts it" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$MON" = "1" ]; then
|
||||
if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
|
||||
echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)"
|
||||
# shellcheck disable=SC2086
|
||||
elif pid="$(start_watchdog update "$WS")" && mam_monitor_started "$WS" $pid; then
|
||||
echo "monitor: restarted (PID $pid)"
|
||||
else
|
||||
echo "ERROR: could not restart the monitor daemon" >&2
|
||||
rc=1
|
||||
fi
|
||||
fi
|
||||
exit "$rc"
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env bash
|
||||
# update.sh — multi-agent-mux-update (Issue #72): update an installed workspace in place.
|
||||
# Usage:
|
||||
# bash .agents/skills/multi-agent-mux-update/scripts/update.sh [-y|--yes]
|
||||
#
|
||||
# Phase 1 (this script, the installed code): publish .mam/update-in-progress
|
||||
# under the admission lock (lib.sh mam_update_admit is the other side), refuse
|
||||
# while anything live could see the tree change (lib_py/update.py precheck, a
|
||||
# live loop marker, a monitor.lock held by a monitor the skills did not start),
|
||||
# stop the monitor daemon, back up what the install can change into
|
||||
# .mam/update-backup/, then exec a copy of install_phase.sh outside the tree.
|
||||
# That copy runs the installer (MAM_INSTALLER_URL) in place, rolls back on
|
||||
# failure, and runs phase 2 (post_update.sh of the NEW tree) on success.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = updated (registry converted or nothing to convert)
|
||||
# 1 = refused or failed; what was changed and restored is printed
|
||||
# 2 = invalid args
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)"
|
||||
export WORKSPACE_ROOT="$WS"
|
||||
export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml"
|
||||
# shellcheck disable=SC1091
|
||||
source "$WS/.agents/skills/lib.sh"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh"
|
||||
|
||||
YES=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y|--yes) YES=1; shift ;;
|
||||
-h|--help) sed -n '2,4p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
MAM="$WS/.mam"
|
||||
MARKER="$MAM/update-in-progress"
|
||||
BK="$MAM/update-backup"
|
||||
PY="$(_delegate_py_bin)"
|
||||
|
||||
if [ ! -f "$WS/.mam_deploy/remove.sh" ] && [ ! -f "$WS/remove.sh" ]; then
|
||||
echo "ERROR: $WS is not an installed MAM workspace (no .mam_deploy/remove.sh); a git checkout updates with git pull" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$YES" != "1" ]; then
|
||||
if [ ! -t 0 ]; then
|
||||
echo "ERROR: not a terminal; run with -y/--yes" >&2
|
||||
exit 1
|
||||
fi
|
||||
ans=""
|
||||
read -r -p "Update MAM in $WS to the latest version? [y/N]: " ans || ans=""
|
||||
case "$ans" in
|
||||
y|Y|yes|Yes|YES) ;;
|
||||
*) echo "Update cancelled."; exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- the marker, published under the exclusive admission lock (decision 8) ---
|
||||
mkdir -p "$MAM"
|
||||
exec 7>>"$MAM/update-admission.lock"
|
||||
case "$("$PY" -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in
|
||||
locked) ;;
|
||||
timeout)
|
||||
echo "ERROR: a create, resume, loop, delegate submit, stop or orc-onboard kept the admission lock past MAM_UPDATE_ADMISSION_WAIT_S; nothing was changed, try again" >&2
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "ERROR: the admission lock $MAM/update-admission.lock could not be decided (lib_py.pane_lock); nothing was changed" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
if [ -f "$MARKER" ] && [ "$(_mam_lock_holder_state "$MARKER")" = "stale" ]; then
|
||||
echo "NOTE: stale update marker (pid $(_mam_lock_field "$MARKER" pid)) taken over; its update is gone"
|
||||
fi
|
||||
marker_rc=0
|
||||
mam_acquire_loop_lock "$MARKER" || marker_rc=$?
|
||||
exec 7>&-
|
||||
case "$marker_rc" in
|
||||
0) ;;
|
||||
3) echo "ERROR: another update is in progress (pid $(_mam_lock_field "$MARKER" pid)); nothing was changed" >&2; exit 1 ;;
|
||||
*) echo "ERROR: cannot tell whether another update is in progress (ps unavailable?); nothing was changed" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
TMPD=""
|
||||
MONITOR_WAS_RUNNING=0
|
||||
RESTART_MONITOR=0
|
||||
_phase1_exit() {
|
||||
local rc=$?
|
||||
if [ "$RESTART_MONITOR" = "1" ]; then
|
||||
_restart_monitor || true
|
||||
fi
|
||||
mam_release_loop_lock "$MARKER" || true
|
||||
[ -z "$TMPD" ] || rm -rf "$TMPD"
|
||||
exit "$rc"
|
||||
}
|
||||
trap _phase1_exit EXIT
|
||||
|
||||
_restart_monitor() {
|
||||
if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
|
||||
echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)"
|
||||
return 0
|
||||
fi
|
||||
local pid
|
||||
pid=$(start_watchdog update "$WS") || { echo "ERROR: could not restart the monitor daemon" >&2; return 1; }
|
||||
# shellcheck disable=SC2086
|
||||
mam_monitor_started "$WS" $pid || { echo "ERROR: could not restart the monitor daemon (PID ${pid:-none} is not its monitor)" >&2; return 1; }
|
||||
echo "monitor: restarted (PID $pid)"
|
||||
}
|
||||
|
||||
_monitor_json() {
|
||||
"$PY" -m lib_py.update monitor --workspace "$WS"
|
||||
}
|
||||
_json_field() {
|
||||
"$PY" -c 'import json,sys; v=json.load(sys.stdin)[sys.argv[1]]; print(" ".join(v) if isinstance(v, list) else v)' "$1"
|
||||
}
|
||||
|
||||
# --- pre-check: refuse before any change (decisions 1, 7; U3) ---
|
||||
BLOCKERS=""
|
||||
_block() { BLOCKERS="${BLOCKERS} - $1
|
||||
"; }
|
||||
|
||||
if [ -f "$MAM/loop-guard-active" ]; then
|
||||
loop_st="$(_mam_lock_holder_state "$MAM/loop-guard-active")"
|
||||
case "$loop_st" in
|
||||
stale) echo "NOTE: stale loop marker $MAM/loop-guard-active (its loop is gone); not a reason to refuse" ;;
|
||||
*) _block "a review loop is running (pid $(_mam_lock_field "$MAM/loop-guard-active" pid), holder $loop_st); wait for it to end" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
STATE_JSON=""
|
||||
if ! STATE_JSON="$(load_state_json strict)"; then
|
||||
_block "the registry $AGENT_SESSIONS_YAML cannot be read; fix it first"
|
||||
STATE_JSON='{}'
|
||||
fi
|
||||
REAL_HERDR="$(_resolve_real_herdr_path || true)"
|
||||
if [ -z "$REAL_HERDR" ]; then
|
||||
_block "no herdr binary found on PATH, so the live agents cannot be listed"
|
||||
else
|
||||
pre_out=""
|
||||
pre_rc=0
|
||||
pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.update precheck --workspace "$WS" --herdr "$REAL_HERDR")" || pre_rc=$?
|
||||
if [ "$pre_rc" -ne 0 ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && _block "$line"
|
||||
done <<< "${pre_out:-lib_py.update precheck failed (rc $pre_rc)}"
|
||||
fi
|
||||
fi
|
||||
|
||||
MON_JSON="$(_monitor_json)" || { _block "cannot inspect the monitor daemon (lib_py.update monitor)"; MON_JSON='{"pids": [], "lock": "free"}'; }
|
||||
MON_PIDS="$(printf '%s' "$MON_JSON" | _json_field pids)"
|
||||
MON_LOCK="$(printf '%s' "$MON_JSON" | _json_field lock)"
|
||||
if [ -z "$MON_PIDS" ] && [ "$MON_LOCK" = "held" ]; then
|
||||
_block "$MAM/monitor.lock is held by a monitor the skills did not start (not 'bash $WS/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --subscribe'); stop it (find it with: pgrep -af reconcile.sh) and try again"
|
||||
fi
|
||||
|
||||
if [ -n "$BLOCKERS" ]; then
|
||||
echo "ERROR: update refused; nothing was changed:" >&2
|
||||
printf '%s' "$BLOCKERS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- stop the monitor daemon (decision 2) ---
|
||||
_monitor_gone() {
|
||||
local p
|
||||
for p in "$@"; do
|
||||
kill -0 "$p" 2>/dev/null && return 1
|
||||
done
|
||||
[ "$(_monitor_json | _json_field lock)" = "free" ]
|
||||
}
|
||||
_wait_monitor_gone() {
|
||||
local deadline=$(( $(date +%s) + $1 ))
|
||||
shift
|
||||
while :; do
|
||||
_monitor_gone "$@" && return 0
|
||||
[ "$(date +%s)" -lt "$deadline" ] || return 1
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
if [ -n "$MON_PIDS" ]; then
|
||||
MONITOR_WAS_RUNNING=1
|
||||
# shellcheck disable=SC2086
|
||||
set -- $MON_PIDS
|
||||
kids=""
|
||||
for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done
|
||||
echo "monitor: stopping the daemon (PID $MON_PIDS)"
|
||||
kill -TERM "$@" 2>/dev/null || true
|
||||
if ! _wait_monitor_gone "$(mam_int_knob MAM_UPDATE_MONITOR_STOP_S 30 1)" "$@"; then
|
||||
# SIGKILL only what is still this workspace's monitor: a PID that ended
|
||||
# during the wait may already be another process's.
|
||||
still=""
|
||||
for p in $(_monitor_json | _json_field pids); do
|
||||
case " $* " in *" $p "*) still="$still $p" ;; esac
|
||||
done
|
||||
# shellcheck disable=SC2086
|
||||
set -- $still
|
||||
for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done
|
||||
echo "WARNING: the monitor did not stop within MAM_UPDATE_MONITOR_STOP_S; sending SIGKILL to $* $kids" >&2
|
||||
# shellcheck disable=SC2086
|
||||
kill -KILL "$@" $kids 2>/dev/null || true
|
||||
# shellcheck disable=SC2086
|
||||
if _wait_monitor_gone 5 "$@" $kids; then
|
||||
echo "ERROR: the monitor did not stop in time; it was killed and is started again. Nothing else was changed." >&2
|
||||
_restart_monitor || true
|
||||
exit 1
|
||||
fi
|
||||
echo "ERROR: could not restore the monitor state: PIDs still alive or monitor.lock still held ($*$kids; lock $(_monitor_json | _json_field lock)). Nothing else was changed." >&2
|
||||
exit 1
|
||||
fi
|
||||
RESTART_MONITOR=1
|
||||
fi
|
||||
|
||||
TMPD="$(mktemp -d "${TMPDIR:-/tmp}/mam-update.XXXXXX")"
|
||||
cp "$SCRIPT_DIR/install_phase.sh" "$TMPD/install_phase.sh"
|
||||
|
||||
# --- a leftover backup of an update that died (U4) ---
|
||||
if [ -e "$BK" ]; then
|
||||
left_st="$(cat "$BK/state" 2>/dev/null || true)"
|
||||
case "$left_st" in
|
||||
""|backing-up|installed)
|
||||
echo "NOTE: removing the backup a previous update left (state ${left_st:-none}); nothing to restore"
|
||||
rm -rf "$BK" ;;
|
||||
installing)
|
||||
echo "NOTE: a previous update died during its install; restoring its backup (the registry is left as it is)"
|
||||
if ! bash "$TMPD/install_phase.sh" restore "$WS" --skip-registry; then
|
||||
RESTART_MONITOR=0
|
||||
echo "ERROR: restoring the previous update's backup failed; it is kept at $BK. Nothing more was changed; the monitor stays stopped." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$BK"
|
||||
echo "the previous update was rolled back" ;;
|
||||
*)
|
||||
echo "ERROR: $BK has an unknown state '$left_st'; it is kept and nothing was changed. Check it and remove it by hand." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- the rollback backup (decisions 3, 10; U4) ---
|
||||
_set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; }
|
||||
ITEMS=".agents .mam_deploy AGENTS.md MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md .mam.env.example .mam.env .gitignore remove.sh update.sh .venv .mam/install_manifest.txt .mam/install_state .mam/asset_hashes.txt .mam/version.txt .mam/base .mam/skill-backups"
|
||||
_backup() {
|
||||
mkdir -p "$BK/files" || return 1
|
||||
_set_state backing-up || return 1
|
||||
: > "$BK/items" && : > "$BK/absent" || return 1
|
||||
local rel
|
||||
for rel in $ITEMS; do
|
||||
printf '%s\n' "$rel" >> "$BK/items" || return 1
|
||||
if [ -e "$WS/$rel" ] || [ -L "$WS/$rel" ]; then
|
||||
mkdir -p "$BK/files/$(dirname "$rel")" && cp -pPR "$WS/$rel" "$BK/files/$rel" || return 1
|
||||
else
|
||||
printf '%s\n' "$rel" >> "$BK/absent" || return 1
|
||||
fi
|
||||
done
|
||||
"$PY" -m lib_py.update backup-registry --workspace "$WS" --dest "$BK/registry" >/dev/null
|
||||
}
|
||||
echo "backup: $BK"
|
||||
if ! _backup; then
|
||||
rm -rf "$BK"
|
||||
echo "ERROR: the backup could not be made; nothing was changed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- hand over to the copy (decision 5): same pid, so the marker stays live ---
|
||||
RESTART_MONITOR=0
|
||||
trap - EXIT
|
||||
exec bash "$TMPD/install_phase.sh" run "$WS" "$MONITOR_WAS_RUNNING" "$TMPD"
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
본 문서는 `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.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# INSTRUCTION.md
|
||||
|
||||
MAM 자체(이 저장소)를 개발할 때 따르는 지침입니다. 설치된 프로젝트에는 복사되지 않습니다.
|
||||
|
||||
## 1. 스킬 작성: 지침보다 코드를 먼저
|
||||
|
||||
- 스킬이 하는 일 가운데 코드로 쓸 수 있는 것(순서, 사전 확인, 거부, 잠금, 정리, 되돌리기)은 SKILL.md 의 지침으로 적지 않고 스크립트에 구현합니다. 그리고 테스트로 고정합니다.
|
||||
- SKILL.md 에는 에이전트의 판단이 필요한 것만 남깁니다.
|
||||
- 언제 이 스킬을 쓰는지(쓰지 않을 때 포함)
|
||||
- 옵션을 어떻게 고르는지
|
||||
- 종료 코드와 출력의 해석
|
||||
- 주의할 점
|
||||
- SKILL.md 가 설명하는 동작은 코드와 같아야 합니다. 둘이 다르면 코드를 기준으로 문서를 고칩니다. 문서대로 동작해야 한다면 코드를 고치고 테스트를 더합니다.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Messaging System Technical Analysis & Architecture Report
|
||||
|
||||
Applies to: MAM v4.10.0
|
||||
Applies to: MAM v4.13.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.
|
||||
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ bash deploy/install.sh
|
||||
- `MQTT_BROKER=localhost` 로 된 초기 `.mam.env` 를 씁니다. 이미 있는 `.mam.env` 는 그대로 둡니다. 예전 `.env` 만 있으면 그것을 `.mam.env` 로 복사하고 원본은 남깁니다.
|
||||
- `.venv`, `.mam/`, `.mam.env` 를 `.gitignore` 에 추가합니다.
|
||||
|
||||
`.mam.env` 의 `MQTT_BROKER` 를 직접 운영하는 브로커로 바꾸십시오(사설 브로커는 [MESSAGING.md](MESSAGING.md) 참고). `MQTT_BROKER` 가 아예 설정되지 않으면 코드는 공개 브로커 `broker.hivemq.com` 을 씁니다. 시험용으로는 괜찮지만 실제 작업용은 아닙니다. `deploy/generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만듭니다(`--force` 는 `.mam.env.bak` 을 남기고 덮어씁니다). 업데이트와 제거는 `deploy/update.sh`, `deploy/remove.sh` 입니다. `deploy/install_mam.sh` 는 로컬 클론에서 다른 디렉터리로 복사해 설치하는 별도 설치기입니다.
|
||||
`.mam.env` 의 `MQTT_BROKER` 를 직접 운영하는 브로커로 바꾸십시오(사설 브로커는 [MESSAGING.md](MESSAGING.md) 참고). `MQTT_BROKER` 가 아예 설정되지 않으면 코드는 공개 브로커 `broker.hivemq.com` 을 씁니다. 시험용으로는 괜찮지만 실제 작업용은 아닙니다. `deploy/generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만듭니다(`--force` 는 `.mam.env.bak` 을 남기고 덮어씁니다). 업데이트는 `multi-agent-mux-update` 스킬, 제거는 `deploy/remove.sh` 입니다. `deploy/install_mam.sh` 는 로컬 클론에서 다른 디렉터리로 복사해 설치하는 별도 설치기입니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,6 +56,8 @@ bash deploy/install.sh
|
||||
| **`multi-agent-mux-delegate-job`** | 세션(`herdr:<name>`)이나 다른 호스트의 러너(`remote:<name>`)에 잡을 위임하고 결과를 기다립니다. [위임 계약](#-위임-계약)을 보십시오. |
|
||||
| **`multi-agent-mux-loop`** | 계획·구현·리뷰: 선택적인 planner 와 creator, 그다음 선택한 리뷰어(`--reviewer A,B`, 또는 `--all-reviewer` 로 creator 를 뺀 실행 중인 리뷰어 전원)가 모두 `PASS` 를 낼 때까지(또는 `--max-loop` 까지). 둘 다 없으면 creator 가 스스로 리뷰합니다. 루프 밖에서 끝난 작업은 `--review-only` 로 리뷰합니다. 통과한 리포트는 `.agents/reports/<session>/` 으로 옮겨 보관합니다. |
|
||||
| **`multi-agent-mux-orc-onboard`** | 오케스트레이터 자신의 세션 ID 를 등록해, 세션 탐색이 그것을 하위 에이전트로 착각하지 않게 합니다. |
|
||||
| **`multi-agent-mux-update`** | 설치된 작업 공간을 제자리에서 업데이트합니다. 실행 중인 세션·열린 잡·루프가 있으면 거부하고, 모니터를 멈췄다가 원래대로 되돌리며, 설치가 실패하면 되돌리고, 새 코드로 레지스트리를 변환합니다. |
|
||||
| **`multi-agent-mux-reset`** | 제어 루트의 실행 중인 상주 에이전트를 하나씩 정리(purge)하고 같은 이름·에이전트·역할·herdr 서버·작업 공간 라벨로 다시 만들며 온보딩합니다(`.mam/reset-spec.json` 으로 중단된 지점부터 재개). |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,7 +197,7 @@ sequenceDiagram
|
||||
│ │ └── *.py # pane_lock, stall_watch, worktree, herdr_config, ...
|
||||
│ └── multi-agent-mux-*/ # 스킬마다 디렉터리 하나 (SKILL.md + scripts/)
|
||||
├── .mam/ # 런타임 상태 (git 제외): 세션, 잡, 감사 로그, shim
|
||||
├── deploy/ # install.sh, install_mam.sh, update.sh, remove.sh, generate-env.sh
|
||||
├── deploy/ # install.sh, install_mam.sh, remove.sh, generate-env.sh
|
||||
├── docs/ # contracts/, design/, 에이전트 통합·브리지 가이드
|
||||
├── nats-docker/ # 사설 NATS 브로커 배포 (서브모듈)
|
||||
├── tests/ # pytest 스위트
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ bash deploy/install.sh
|
||||
- writes a starter `.mam.env` with `MQTT_BROKER=localhost`. An existing `.mam.env` is kept. If only a legacy `.env` exists, it is copied to `.mam.env` and the original stays;
|
||||
- adds `.venv`, `.mam/` and `.mam.env` to `.gitignore`.
|
||||
|
||||
Point `MQTT_BROKER` in `.mam.env` at a broker you run (see [MESSAGING.md](MESSAGING.md) for a private broker). If `MQTT_BROKER` is not set at all, the code falls back to the public `broker.hivemq.com`. That is fine for a trial, but not for real work. `deploy/generate-env.sh` creates `.mam.env` from `.mam.env.example` when it is missing (`--force` overwrites it, keeping a `.mam.env.bak`). `deploy/update.sh` and `deploy/remove.sh` update and uninstall. `deploy/install_mam.sh` is a separate installer that copies from a local clone into another directory.
|
||||
Point `MQTT_BROKER` in `.mam.env` at a broker you run (see [MESSAGING.md](MESSAGING.md) for a private broker). If `MQTT_BROKER` is not set at all, the code falls back to the public `broker.hivemq.com`. That is fine for a trial, but not for real work. `deploy/generate-env.sh` creates `.mam.env` from `.mam.env.example` when it is missing (`--force` overwrites it, keeping a `.mam.env.bak`). The `multi-agent-mux-update` skill updates an installed workspace, and `deploy/remove.sh` uninstalls. `deploy/install_mam.sh` is a separate installer that copies from a local clone into another directory.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,6 +56,8 @@ Every skill lives under `.agents/skills/` with a `SKILL.md` that documents its o
|
||||
| **`multi-agent-mux-delegate-job`** | Delegates a job to a session (`herdr:<name>`) or to a runner on another host (`remote:<name>`) and waits for its result. See [Delegation contract](#-delegation-contract). |
|
||||
| **`multi-agent-mux-loop`** | Plan, implement, review: an optional planner and a creator, then the selected reviewers (`--reviewer A,B`, or `--all-reviewer` for every running reviewer except the creator) until all of them `PASS` or `--max-loop` is reached. With neither option the creator reviews its own work. `--review-only` reviews work finished outside the loop. Passing reports are promoted to `.agents/reports/<session>/`. |
|
||||
| **`multi-agent-mux-orc-onboard`** | Registers the orchestrator's own session id, so session discovery never mistakes it for a sub-agent. |
|
||||
| **`multi-agent-mux-update`** | Updates an installed workspace in place: refuses while sessions, open jobs or a loop are live; stops and restores the monitor; rolls back on a failed install; converts the registry with the new code. |
|
||||
| **`multi-agent-mux-reset`** | Purges the control root's running resident agents and recreates each with the same name, agent, role, herdr server and workspace label, one at a time, with onboarding (`.mam/reset-spec.json` for resumable reruns). |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,7 +197,7 @@ sequenceDiagram
|
||||
│ │ └── *.py # pane_lock, stall_watch, worktree, herdr_config, ...
|
||||
│ └── multi-agent-mux-*/ # One directory per skill (SKILL.md + scripts/)
|
||||
├── .mam/ # Runtime state (git-ignored): sessions, jobs, audit logs, shim
|
||||
├── deploy/ # install.sh, install_mam.sh, update.sh, remove.sh, generate-env.sh
|
||||
├── deploy/ # install.sh, install_mam.sh, remove.sh, generate-env.sh
|
||||
├── docs/ # contracts/, design/, agent integration and bridge guides
|
||||
├── nats-docker/ # Private NATS broker deployment (submodule)
|
||||
├── tests/ # pytest suite
|
||||
|
||||
+116
-12
@@ -6,8 +6,8 @@
|
||||
|
||||
## 📌 현재 버전 개요 (Current Release)
|
||||
|
||||
- **프레임워크 버전**: `v4.10.0`
|
||||
- **최신 릴리스 일시**: 2026-10-23 (KST)
|
||||
- **프레임워크 버전**: `v4.13.0`
|
||||
- **최신 릴리스 일시**: 2026-10-29 (KST)
|
||||
- **기준 브랜치**: `main`
|
||||
- **핵심 아키텍처**:
|
||||
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
|
||||
@@ -18,29 +18,133 @@
|
||||
- **워크트리 운영 모델 2단계 (Issue #21 항목 4·5·7)**: 워크트리 준비·해체 헬퍼(변경 전 판정, untracked 포함, 검증된 단일 `--force`), 리뷰 identity(라운드별 스냅샷·stale 재리뷰·승격 직전 재확인·`identity.json`), 수용 사례 A1–A8.
|
||||
- **잡 예산·라벨·멈춤 알림 (Issue #33·#30)**: 위임 잡 예산은 `--job-timeout`/`MAM_JOB_TIMEOUT_S`, 벽시계 초과는 `wall_clock_timeout`(무응답 `deadline_timeout` 과 구분), `lib_py/stall_watch.py` 가 `blocked`/`idle` 을 `progress stalled:*` 로 알리기만 한다(종결 근거는 #18 의 프로토콜 이벤트뿐).
|
||||
- **설정 파일 로더 하나 (Issue #26)**: `.mam.env` 는 `lib_py/delegation/envfile.py` 한 곳이 읽는다(셸 해석 없음, 환경변수 우선, 읽을 수 없는 파일은 오류). bash 산술에 들어가는 정수 설정값은 `mam_int` 로 10진수.
|
||||
- **4-Way 버전 락스텝**: `lib.sh`(`MAM_VERSION`) ↔ `lib_py/loop/eager.py`(`FRAMEWORK_VERSION`) ↔ `VERSIONS.md` ↔ 8개 `SKILL.md`.
|
||||
- **4-Way 버전 락스텝**: `lib.sh`(`MAM_VERSION`) ↔ `lib_py/loop/eager.py`(`FRAMEWORK_VERSION`) ↔ `VERSIONS.md` ↔ 9개 `SKILL.md`.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.0`로 동기화되어 배포됩니다.
|
||||
모든 9개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.13.0`로 동기화되어 배포됩니다.
|
||||
|
||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||
| :--- | :---: | :--- | :---: |
|
||||
| **`multi-agent-mux-create`** | `4.10.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.10.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.10.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.10.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.10.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.10.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.10.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.10.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-create`** | `4.13.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.13.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.13.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.13.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.13.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.13.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.13.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.13.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-update`** | `4.13.0` | 설치된 작업 공간의 제자리 업데이트·되돌리기와 업데이트 중 입장 차단 | ✅ 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||
|
||||
### 🚀 `v4.13.0` — worktree creator 도 기본으로 온보딩하고, create 의 --no-onboard 는 ID 가 나중에 생기는 에이전트에 경고한다 (Issue #68) (2026-10-29)
|
||||
|
||||
> **마이너 (MINOR Release)**: `worktree_prepare.sh --creator-agent` 가 온보딩 없이 creator 를 만들어, agy·codex·hermes·opencode 는 첫 대화 전까지 레지스트리에 대화 ID 가 없었다. 이제 worktree creator 도 기본으로 온보딩한다. create 의 `--no-onboard` 는 남기고, ID 가 첫 턴 뒤에야 생기는 에이전트에 쓰면 경고한다. 공개 스크립트의 기본 동작이 바뀌어 MINOR 로 올린다. PR #79.
|
||||
|
||||
#### ⚠️ 호환성
|
||||
- `worktree_prepare.sh --creator-agent` 가 기본으로 온보딩한다. 온보딩이 끝날 때까지 기다리므로(`MAM_ONBOARD_WAIT_S`, 기본 600 s) 전보다 오래 걸리고, create 의 종료 코드 44(온보딩 `error`)·45(시간 초과)가 나올 수 있다. 건너뛰려면 `--no-onboard` 를 준다. `--onboard` 는 기본과 같은 뜻으로 계속 받는다. 둘 다 주면 마지막 것이 이긴다.
|
||||
- create 에 `--no-onboard` 를 agy·codex·hermes·opencode 와 함께 주면 stderr 에 경고 한 줄이 나온다. 종료 코드와 레지스트리 행은 바뀌지 않는다. claude·grok 에는 나오지 않는다.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **경고 (`create_session.sh`, `lib_py/agents/__main__.py`)**: `lib_py.agents facts` 가 어댑터의 `id_lifecycle` 을 `MAM_ID_POST_DISCOVERED` 로 내보내고, create 는 이 값으로 판정한다(셸에 에이전트 목록을 두지 않는다). 첫 턴 전까지 ID 가 레지스트리에 없고, 같은 폴더에 같은 종류를 또 만들면 그 create 가 기다린다(#63, `MAM_ID_ORDER_WAIT_S`)는 내용이다. spawn 전에 출력하므로 `--dry-run` 에서도, 뒤에서 create 가 실패해도 보인다.
|
||||
2. **worktree creator (`worktree_prepare.sh`)**: 기본으로 create 에 `--onboard` 를 넘긴다. 새 `--no-onboard` 는 create 에 그대로 넘긴다. 사용법과 종료 코드 주석을 고쳤다.
|
||||
3. **문서**: create `SKILL.md` 의 옵션 표 한 줄과 worktree 절(시놉시스, `--creator-agent` 설명, 종료 코드).
|
||||
|
||||
#### ✅ 품질 검증
|
||||
- **회귀**: `tests/test_issue68_onboard_default.py`(25개). base(v4.12.0)에서 20개가 실패한다. 나머지 5개는 base 에서도 맞아야 하는 경우다(claude·grok 은 경고 없음, `--onboard` 전달). 기본이 온보딩하지 않는 것을 전제로 하던 worktree 테스트 호출 3곳에 `--no-onboard` 를 붙였다.
|
||||
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프)**: 작성자 루프 1회차 NOT PASS(`test_issue21_acceptance.py` 의 암묵적 no-onboard 호출 2곳), 2회차 4/4 PASS. 병합 전 리뷰 전용 루프(@3b81f99) 1회차 **4/4 PASS**. 리뷰어들이 `SKILL.md` 의 설명을 코드와 줄 단위로 대조했다. 그 지적으로 `SKILL.md` 의 "막힌다" 를 "기다린다" 로 고치고 쓰이지 않는 import 를 지웠다(@a7bf07b). 리포트는 `.agents/reports/<session>/` 에 있다.
|
||||
- **전체 스위트 (macOS, Python 3.9.6, @a7bf07b)**: 2293 passed, 56 skipped, 실패 0 (102분). 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config·본 체크아웃의 `.mam` 에 이 실행이 남긴 것은 없었고, 본 체크아웃의 모니터는 그대로 떠 있었다.
|
||||
- **작성자 확인 (Linux, @7cf63f4)**: 2327 passed, 20 skipped, 2 failed. 하나는 이번 변경의 분류 누락이었고 3b81f99 에서 고쳤다. 다른 하나(`test_issue34_job_tree.py::test_t5b_…[True]`)는 단독 실행에서 head·base 모두 통과했다.
|
||||
- **알려진 비차단**: `--no-onboard --submit-job` 을 함께 주면 제출한 잡이 첫 턴이 되는데도 경고가 나온다(결정 3 의 조건 그대로). `worktree_prepare.sh --path ../<상대 경로>` 가 부모 폴더가 없을 때 "제어 루트 안" 으로 거부되는 문제는 별도 버그다.
|
||||
|
||||
### 🚀 `v4.12.0` — update 스킬로 제자리 업데이트하고, 업데이트 중에는 세션·잡·루프의 시작을 막는다 (Issue #72) (2026-10-28)
|
||||
|
||||
> **마이너 (MINOR Release)**: 설치된 작업 공간의 업데이트를 `.mam_deploy/update.sh` 대신 새 `multi-agent-mux-update` 스킬이 한다. 업데이트는 실행 중인 세션·열린 잡·루프가 있으면 거부하고, 모니터를 멈췄다가 다시 띄우며, 설치가 실패하면 되돌린다. 업데이트 중에는 create·resume·run_loop·delegate submit·stop·orc-onboard 가 거부한다. resume 이 모니터 데몬을 띄운다. 공개 진입점(`.mam_deploy/update.sh`)이 없어지고 기존 명령의 동작(업데이트 중 거부)이 늘어 MINOR 로 올린다. PR #75.
|
||||
|
||||
#### ⚠️ 호환성
|
||||
- `.mam_deploy/update.sh` 와 `deploy/update.sh` 가 없어졌다. 업데이트는 `bash .agents/skills/multi-agent-mux-update/scripts/update.sh --yes` 로 한다. 설치기(`deploy/install.sh`)는 남아 있는 옛 `update.sh` 를 지운다.
|
||||
- 업데이트 중(`.mam/update-in-progress` 표식이 live/unknown)에는 create·resume·run_loop·delegate submit·stop·orc-onboard 가 거부한다. 이 명령들은 `.mam/update-admission.lock` 을 공유로 잡고, 기다리는 시간은 `MAM_UPDATE_ADMISSION_WAIT_S`(기본 120 s)다. `.mam` 이 없는 트리(개발 체크아웃)에서는 잠금을 만들지 않는다.
|
||||
- resume 이 끝나면 모니터 데몬(`reconcile.sh --subscribe`)을 띄운다. 이미 떠 있으면 새로 띄우지 않고, `MAM_NO_SUBSCRIBER_DAEMON=1` 이면 띄우지 않는다.
|
||||
- create·delegate-job·stop 의 attach 안내는 행의 `attach_command`(`herdr --session <server> agent attach <herdr 이름>`)를 보여 준다.
|
||||
- **첫 전환 (v4.11.0 이하 설치본, 옛 `update.sh` 로 한 번)**:
|
||||
1. 루프가 끝나기를 기다리고, 열린 잡을 끝내거나 닫는다.
|
||||
2. 세션을 모두 stop 한다.
|
||||
3. 모니터(`reconcile.sh --subscribe`)를 멈춘다.
|
||||
4. 옛 `.mam_deploy/update.sh` 를 실행한다.
|
||||
5. 바로 update 스킬을 한 번 더 실행한다(레지스트리 변환).
|
||||
- 4번의 옛 `update.sh` 는 `.mam` 을 지운 뒤 고정 목록만 되돌리므로 `herdr_workspace_id`, `diagnostics/`, 모니터 로그, `shim/`(lib.sh 가 다시 만듦), 있다면 `briefs/`·`resumelogs/`·`stoplogs/` 를 잃는다.
|
||||
- 첫 전환 뒤에는 모니터가 없다. resume 하면 뜬다.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **update 스킬 (`multi-agent-mux-update`: `scripts/update.sh`, `install_phase.sh`, `post_update.sh`, `lib_py/update.py`)**:
|
||||
- 1단계(설치된 옛 코드): 배타 입장 잠금 아래에서 표식을 걸고, 아무것도 바꾸기 전에 거부할 것을 확인한다(running 행, cwd 가 이 작업 공간 안인 살아 있는 herdr 에이전트, 읽을 수 없는 것을 포함한 열린 잡, live 루프, 스킬이 띄우지 않은 모니터가 쥔 `.mam/monitor.lock`, 다른 업데이트). 호출한 에이전트(서버+pane)는 세지 않는다.
|
||||
- 이 작업 공간의 모니터만(정확한 명령줄로) 멈추고(TERM, 끝나지 않으면 KILL; `MAM_UPDATE_MONITOR_STOP_S`), `.agents/`·`.mam_deploy/`·루트 파일·`.venv`·설치기가 관리하는 `.mam` 파일·레지스트리를 `.mam/update-backup/` 에 백업한 뒤, 설치 단계의 복사본을 트리 밖에서 exec 한다.
|
||||
- 설치(bash 3.2, lib.sh 를 부르지 않음): `MAM_INSTALLER_URL` 의 설치기를 제자리로 실행한다. 실패하면 백업에서 되돌리고 되돌린 트리로 모니터를 다시 띄운다.
|
||||
- 2단계(새 코드): `lib_py.registry_format` 으로 레지스트리를 변환하고, 원래 떠 있던 모니터만 다시 띄운다. 변환 실패는 변환기의 코드로 끝난다.
|
||||
- 남은 백업은 상태(`backing-up` → `installing` → `installed`)에 따라 지우거나 되돌린 뒤 진행한다. 되돌리기가 실패하면 백업을 두고 멈춘다.
|
||||
2. **입장 잠금 (`lib.sh` `mam_update_admit`, `lib_py.pane_lock acquire --shared --wait-env`)**: 위 여섯 명령이 기록을 쓸 때까지 공유 잠금을 쥔다. 오래 사는 자식은 그 fd 를 물려받지 않는다. delegate submit 과 create·resume 은 대상 pane 이 속한 설치 작업 공간의 잠금도 잡는다.
|
||||
3. **제자리 설치 (`deploy/install.sh`)**: `install_state` 를 키 단위로 덮어쓴다. 새 버전에 없는 프레임워크 파일은 사용자가 고치지 않았으면 지우고, 고쳤으면 남기고 목록을 출력한다.
|
||||
4. **resume 의 모니터 (`resume_session.sh`)**: 행을 쓴 뒤 제어 루트를 workdir 로 `start_watchdog` 를 부른다(worktree 세션 포함). 실패는 경고만 한다. legacy `start_watchdog`(`MAM_DELEGATION_PY=0`)도 모니터를 자기 프로세스 그룹에서 띄운다.
|
||||
5. **attach 안내**: create 의 마지막 안내, delegate-job 의 전달 안내, stop 의 TUI 멈춤 안내, `deploy/INSTALL.md`, `install_mam.sh`.
|
||||
6. **버전 락스텝**: update 스킬이 매트릭스와 `test_version_consistency.py` 의 스킬 목록에 들어가 9개 `SKILL.md` 를 맞춘다.
|
||||
|
||||
#### ✅ 품질 검증
|
||||
- **회귀**: `tests/test_issue72_{update,admission,attach_hints,installer,resume_monitor}.py`. base(v4.11.0)에서 78개 중 77개가 실패하고 1개는 건너뛴다(첫 전환 테스트, base 아카이브에 git 기록이 없어서).
|
||||
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프)**: 1회차 4명 모두 NOT PASS(모니터를 정확한 명령줄로 고르기, pane 작업 공간의 입장, 레지스트리 스냅샷을 행 단위로 비교, macOS 의 `-shm`). agy 의 지적은 creator 의 반론으로 철회됐다. 2·3회차 3/4 PASS, codex 가 legacy `start_watchdog` 의 프로세스 그룹과 테스트의 패턴 kill 정리를 지적했다. 수정 루프(@ec7f5c4)에서 **4/4 PASS**. 리포트는 `.agents/reports/<session>/` 에 있다.
|
||||
- **전체 스위트 (macOS, Python 3.9.6, @ec7f5c4)**: 2268 passed, 56 skipped, 실패 0 (100분). 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config·본 체크아웃의 `.mam` 에 이 실행이 남긴 것은 없었고, 본 체크아웃의 모니터는 그대로 떠 있었다.
|
||||
- **작성자 확인 (Linux, @253a0aa)**: 2291 passed, 20 skipped, 실패 0.
|
||||
- **알려진 비차단**: stale 표식 NOTE 의 출력 순서(다른 업데이트가 끼어들면 메시지가 둘). `MAM_STATE_JSON` 이 깨졌을 때 delegate submit 이 대상 해석을 건너뛴다(등록은 뒤의 레지스트리 접근에서 실패). 행 없는 대상에 submit 할 때마다 herdr 를 한 번 더 조회한다. 입장 경합을 실제 create 로 재현하는 테스트, 기존 `.venv` 내용의 복원 테스트, 되돌리기 뒤 DB 전체 비교 테스트는 없다.
|
||||
|
||||
### 🚀 `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)
|
||||
|
||||
> **패치 (PATCH Release)**: 세션 이름이 herdr 의 에이전트 이름 규칙(소문자 `[a-z0-9_-]`, 영문자로 시작, 32자 이하)을 벗어나면, 그 세션에 위임한 잡의 `stall_watch` 가 pane 을 찾지 못하고(`stall_watch:agent_not_found`) 멈춤 알림이 꺼졌다. 이제 herdr 를 부를 때만 `sanitize_herdr_agent_name()` 의 결과를 쓴다. 옵션·동작 계약은 바뀌지 않는다. PR #70.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **herdr 호출 (`lib_py/stall_watch.py`)**: `HerdrSource` 의 `agent get`·`agent read`·`agent prompt` 와 `StallWatch` 의 `watcher.wait_once` 가 변환된 이름을 쓴다. 32자를 넘는 이름(앞 23자 + `-` + sha1 8자), 대문자·`.` 이 든 이름이 해당한다. 이미 규칙에 맞는 이름은 그대로다.
|
||||
2. **원래 이름이 남는 곳**: 로그와 알림은 원래 세션 이름을 쓴다. 에이전트 종류(`resolve_agent`)도 원래 이름의 `-<agent>` 접미사로 정한다. 긴 이름은 변환되면 접미사가 해시로 바뀌기 때문이다.
|
||||
3. **mock herdr (`tests/conftest.py`)**: `strict_agent_names` 옵션을 켜면 실제 herdr 처럼 등록된(변환된) 이름에만 답하고, 다른 이름에는 `agent_not_found` 를 낸다. 기본은 꺼져 있어 기존 테스트의 동작은 같다.
|
||||
|
||||
#### ✅ 품질 검증
|
||||
- **회귀**: `tests/test_issue66_stall_watch_sanitized_name.py`(7개). base(v4.10.0)에서 5개가 실패한다. 긴 이름·대문자 이름의 idle 재촉과 blocked 알림, 원래 이름의 로그, 접미사로 정하는 에이전트 종류, 규칙에 맞는 이름의 무변경을 확인한다.
|
||||
- **리뷰 (`--all-reviewer`, 작성자 PR 의 리뷰 전용 루프)**: 1회차(@bb496fd) **4/4 PASS**. lib.sh `_sanitize_herdr_agent_name` 과 Python 함수의 결과가 같은 것도 확인했다. 리포트는 `.agents/reports/<session>/` 에 있다.
|
||||
- **전체 스위트 (macOS, Python 3.9.6, @bb496fd)**: 2105 passed, 56 skipped, 실패 0. 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다.
|
||||
- **작성자 확인 (Linux)**: 전체 스위트 2140 passed, 20 skipped, 1 failed. 실패는 `tests/test_issue17_item4_decisions.py` 의 정리 경합 하나이고 단독으로 10번 모두 통과했다.
|
||||
- **알려진 비차단**: lib.sh 에 `_sanitize_herdr_agent_name` 정의가 두 벌(:36, :293) 있다. 뒤의 것이 적용되고, sha 도구가 없을 때의 대처만 다르다. 이 PR 이전부터 있던 것이다.
|
||||
|
||||
### 🚀 `v4.10.0` — 같은 폴더의 같은 종류 에이전트가 서로의 대화 ID 를 쓰지 않는다 (Issue #63) (2026-10-23)
|
||||
|
||||
> **마이너 (MINOR Release)**: 같은 폴더에 ID 가 나중에 생기는 같은 종류 에이전트(agy·codex·hermes·opencode)가 둘 있으면, stop·purge·resume 이 `find_workspace_uuid` 의 추측으로 다른 행의 대화 ID 를 기록하거나 지우거나 이어받을 수 있었다. 이제 세 경로가 #40 의 판정을 같이 쓰고, 같은 폴더의 생성 순서를 정하는 새 대기 동작(`MAM_ID_ORDER_WAIT_S`)과 resume 의 새 거부(exit 1)가 추가되어 MINOR 로 올린다. PR #69.
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
|
||||
### 2) 세션 접속 (Attach)
|
||||
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
|
||||
```bash
|
||||
$ herdr session attach my-project-dev-claude
|
||||
$ herdr --session multi-agent-mux agent attach my-project-dev-claude
|
||||
```
|
||||
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
|
||||
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ This directory contains packaging templates and installation scripts to deploy t
|
||||
* **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), sets up a local python virtual environment (`.venv`), and performs 3-way refresh with key-level registry merge (Rev.2).
|
||||
* **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, manifests, and asset hashes into target projects.
|
||||
* **`lib_ownership.sh`**: Single source of truth for framework-owned files and key-level registry files.
|
||||
* **`update.sh`**: In-place updater script installed into target `.mam_deploy/update.sh`.
|
||||
* **Updates**: installed workspaces update with the `multi-agent-mux-update` skill (`bash .agents/skills/multi-agent-mux-update/scripts/update.sh`).
|
||||
* **`remove.sh`**: Clean uninstaller script installed into target `.mam_deploy/remove.sh`.
|
||||
* **`generate-env.sh`**: Environment configuration bootstrap helper.
|
||||
* **`INSTALL.md`**: Detailed installation and quick-start user manual.
|
||||
@@ -40,7 +40,7 @@ bash deploy/install_mam.sh --target /path/to/your/project
|
||||
Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows.
|
||||
|
||||
> [!NOTE]
|
||||
> `install_mam.sh` automatically deploys `.mam_deploy/update.sh` and `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls.
|
||||
> `install_mam.sh` automatically deploys `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls.
|
||||
|
||||
### 3. Custom Fork / Private Mirror Installations
|
||||
If you run a private mirror or fork, you can override the source URLs during installation using environment variables:
|
||||
@@ -53,7 +53,7 @@ curl -fsSL https://my-mirror.example.com/.../install.sh \
|
||||
bash
|
||||
|
||||
# Updating an existing workspace against a mirror
|
||||
MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash deploy/update.sh
|
||||
MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash .agents/skills/multi-agent-mux-update/scripts/update.sh
|
||||
```
|
||||
|
||||
### 4. Registering as a Workspace Plugin
|
||||
|
||||
+2
-1
@@ -41,7 +41,8 @@ jobs:
|
||||
shellcheck deploy/install.sh
|
||||
shellcheck deploy/install_mam.sh
|
||||
shellcheck deploy/generate-env.sh
|
||||
shellcheck deploy/update.sh
|
||||
shellcheck .agents/skills/multi-agent-mux-update/scripts/*.sh
|
||||
shellcheck .agents/skills/multi-agent-mux-reset/scripts/*.sh
|
||||
shellcheck deploy/remove.sh
|
||||
echo "✅ ShellCheck completed successfully."
|
||||
|
||||
|
||||
+71
-18
@@ -125,7 +125,9 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre
|
||||
FETCH_METHOD="archive"
|
||||
if [ -d "$REPO_URL" ]; then
|
||||
echo "🌐 Copying local working tree into a staging area..."
|
||||
cp -R "$REPO_URL/." "$STAGE_DIR/"
|
||||
# Everything but .mam/: runtime state is never staged, and it may hold the
|
||||
# update skill's rollback copy (.mam/update-backup/, Issue #72).
|
||||
find "$REPO_URL/." -mindepth 1 -maxdepth 1 ! -name .mam -exec cp -R {} "$STAGE_DIR/" \;
|
||||
FETCH_METHOD="local"
|
||||
elif command -v git &>/dev/null; then
|
||||
echo "🌐 Cloning repository (shallow) into a staging area..."
|
||||
@@ -157,9 +159,16 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre
|
||||
MANIFEST_FILE=".mam/install_manifest.txt"
|
||||
touch "$MANIFEST_FILE"
|
||||
|
||||
# Migrate legacy root layout for remove.sh and update.sh into .mam_deploy/ if owned
|
||||
# Drop every line naming this path from the manifest
|
||||
manifest_drop() {
|
||||
grep -Fvx -- "$1" "$MANIFEST_FILE" > "$MANIFEST_FILE.tmp" || true
|
||||
mv -f "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
|
||||
}
|
||||
|
||||
# Migrate legacy root layout for remove.sh into .mam_deploy/ if owned
|
||||
mkdir -p .mam_deploy
|
||||
for legacy_script in remove.sh update.sh; do
|
||||
# shellcheck disable=SC2043 # one legacy script left (update.sh went with Issue #72)
|
||||
for legacy_script in remove.sh; do
|
||||
if [ -f "$legacy_script" ] && grep -Fqx "$legacy_script" "$MANIFEST_FILE" 2>/dev/null; then
|
||||
mv -f "$legacy_script" ".mam_deploy/$legacy_script"
|
||||
python3 -c '
|
||||
@@ -179,6 +188,13 @@ with open(path, "w") as f:
|
||||
fi
|
||||
done
|
||||
|
||||
# update.sh is no longer shipped (Issue #72; the multi-agent-mux-update skill
|
||||
# updates). A legacy root update.sh goes only if the manifest says we put it there.
|
||||
if [ -f update.sh ] && grep -Fqx update.sh "$MANIFEST_FILE" 2>/dev/null; then
|
||||
rm -f update.sh
|
||||
manifest_drop update.sh
|
||||
fi
|
||||
|
||||
# Safe refresh & fingerprint checking logic
|
||||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
FRAMEWORK_LEDGER=".mam/.framework_ledger.tmp"
|
||||
@@ -374,7 +390,7 @@ MERGEREG
|
||||
fi
|
||||
done
|
||||
|
||||
# Install remove.sh and update.sh into .mam_deploy/
|
||||
# Install remove.sh into .mam_deploy/
|
||||
mkdir -p .mam_deploy
|
||||
if [ -f "$STAGE_DIR/deploy/remove.sh" ]; then
|
||||
cp "$STAGE_DIR/deploy/remove.sh" .mam_deploy/remove.sh
|
||||
@@ -384,13 +400,9 @@ MERGEREG
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$STAGE_DIR/deploy/update.sh" ]; then
|
||||
cp "$STAGE_DIR/deploy/update.sh" .mam_deploy/update.sh
|
||||
chmod 0755 .mam_deploy/update.sh
|
||||
if ! grep -Fqx ".mam_deploy/update.sh" "$MANIFEST_FILE" 2>/dev/null; then
|
||||
echo ".mam_deploy/update.sh" >> "$MANIFEST_FILE"
|
||||
fi
|
||||
fi
|
||||
# An older install's updater is not shipped any more (Issue #72)
|
||||
rm -f .mam_deploy/update.sh
|
||||
manifest_drop .mam_deploy/update.sh
|
||||
|
||||
if [ -f "$STAGE_DIR/.mam.env.example" ] && [ ! -e ".mam.env.example" ]; then
|
||||
cp "$STAGE_DIR/.mam.env.example" .
|
||||
@@ -407,6 +419,34 @@ MERGEREG
|
||||
fi
|
||||
fi
|
||||
|
||||
# Framework-owned files the previous install recorded (manifest and
|
||||
# asset_hashes.txt) that this version no longer ships (Issue #72). Unmodified
|
||||
# ones are deleted. Modified ones are kept and become the user's file: they
|
||||
# leave the manifest, so remove.sh leaves them alone, and asset_hashes.txt
|
||||
# (rebuilt below from the shipped files only).
|
||||
if [ -f .mam/asset_hashes.txt ]; then
|
||||
SHIPPED_LIST=".mam/.shipped_paths.tmp"
|
||||
cut -f2 "$FRAMEWORK_LEDGER" > "$SHIPPED_LIST"
|
||||
while read -r _old_sha _old_path; do
|
||||
[ -n "${_old_path:-}" ] || continue
|
||||
is_framework_owned "$_old_path" || continue
|
||||
grep -Fqx -- "$_old_path" "$MANIFEST_FILE" || continue
|
||||
if grep -Fqx -- "$_old_path" "$SHIPPED_LIST"; then continue; fi
|
||||
if [ -f "$_old_path" ] && [ "$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$_old_path")" != "$_old_sha" ]; then
|
||||
echo "ℹ️ Local modification detected (no longer shipped, kept as yours): $_old_path" >&2
|
||||
else
|
||||
rm -f "$_old_path"
|
||||
_old_dir="$(dirname "$_old_path")"
|
||||
while [ "$_old_dir" != ".agents" ] && rmdir "$_old_dir" 2>/dev/null; do
|
||||
_old_dir="$(dirname "$_old_dir")"
|
||||
done
|
||||
fi
|
||||
rm -f ".mam/base/$_old_path"
|
||||
manifest_drop "$_old_path"
|
||||
done < .mam/asset_hashes.txt
|
||||
rm -f "$SHIPPED_LIST"
|
||||
fi
|
||||
|
||||
# Re-build asset_hashes.txt from the UPSTREAM copy of every framework-owned file
|
||||
python3 - .mam/asset_hashes.txt "$FRAMEWORK_LEDGER" <<'HASHDB'
|
||||
import os, hashlib, sys
|
||||
@@ -590,15 +630,25 @@ with open(gi_path, "w") as f:
|
||||
PY
|
||||
|
||||
if [ "${MAM_SKIP_GITIGNORE:-0}" -eq 0 ]; then
|
||||
# Set the key: overwrite it in place, one line per key, other keys kept
|
||||
python3 -c '
|
||||
import os
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
lines = []
|
||||
if os.path.exists(".mam/install_state"):
|
||||
with open(".mam/install_state", "a") as f:
|
||||
f.write("gitignore_created=1\n")
|
||||
else:
|
||||
os.makedirs(".mam", exist_ok=True)
|
||||
with open(".mam/install_state", "w") as f:
|
||||
f.write("gitignore_created=1\n")
|
||||
with open(".mam/install_state") as f:
|
||||
lines = f.read().splitlines()
|
||||
out, done = [], False
|
||||
for line in lines:
|
||||
if line.startswith("gitignore_created="):
|
||||
if done:
|
||||
continue
|
||||
line, done = "gitignore_created=1", True
|
||||
out.append(line)
|
||||
if not done:
|
||||
out.append("gitignore_created=1")
|
||||
with open(".mam/install_state", "w") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
'
|
||||
fi
|
||||
|
||||
@@ -617,6 +667,9 @@ cat <<EOF
|
||||
$ bash .agents/skills/multi-agent-mux-status/scripts/status.sh
|
||||
|
||||
3. Update installed skills in the future:
|
||||
$ bash .mam_deploy/update.sh
|
||||
$ bash .agents/skills/multi-agent-mux-update/scripts/update.sh
|
||||
|
||||
4. Reset running control-root agents with fresh conversations:
|
||||
$ bash .agents/skills/multi-agent-mux-reset/scripts/reset.sh --dry-run
|
||||
--------------------------------------------------------------------------------
|
||||
EOF
|
||||
@@ -132,16 +132,12 @@ if [ -f "$SRC_DIR/.mam.env.example" ]; then
|
||||
log_ok "Copied .mam.env.example configuration template"
|
||||
fi
|
||||
|
||||
# Deploy remove.sh and update.sh into .mam_deploy/ (R-3)
|
||||
# Deploy remove.sh into .mam_deploy/ (R-3)
|
||||
mkdir -p "$TARGET_DIR/.mam_deploy"
|
||||
if [ -f "$SRC_DIR/deploy/remove.sh" ]; then
|
||||
cp "$SRC_DIR/deploy/remove.sh" "$TARGET_DIR/.mam_deploy/remove.sh"
|
||||
chmod 0755 "$TARGET_DIR/.mam_deploy/remove.sh"
|
||||
fi
|
||||
if [ -f "$SRC_DIR/deploy/update.sh" ]; then
|
||||
cp "$SRC_DIR/deploy/update.sh" "$TARGET_DIR/.mam_deploy/update.sh"
|
||||
chmod 0755 "$TARGET_DIR/.mam_deploy/update.sh"
|
||||
fi
|
||||
|
||||
if [ -f "$SRC_DIR/deploy/generate-env.sh" ]; then
|
||||
mkdir -p "$TARGET_DIR/scripts"
|
||||
@@ -161,7 +157,7 @@ mkdir -p "$TARGET_DIR/.mam"
|
||||
MANIFEST_FILE="$TARGET_DIR/.mam/install_manifest.txt"
|
||||
: > "$MANIFEST_FILE"
|
||||
( cd "$TARGET_DIR" && find .agents -type f -print ) >> "$MANIFEST_FILE"
|
||||
for extra in ".mam.env.example" ".mam_deploy/remove.sh" ".mam_deploy/update.sh" \
|
||||
for extra in ".mam.env.example" ".mam_deploy/remove.sh" \
|
||||
"scripts/generate-env.sh"; do
|
||||
if [ -e "$TARGET_DIR/$extra" ]; then
|
||||
echo "$extra" >> "$MANIFEST_FILE"
|
||||
@@ -327,7 +323,7 @@ cat <<EOF
|
||||
--herdr-server multi-agent-mux
|
||||
|
||||
2. Attach to the running session:
|
||||
$ HERDR_SERVER_NAME=multi-agent-mux herdr agent attach <session_name>
|
||||
$ herdr --session multi-agent-mux agent attach <herdr name>
|
||||
|
||||
3. Gracefully stop the session:
|
||||
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\
|
||||
|
||||
@@ -92,6 +92,8 @@ else
|
||||
".agents/skills/multi-agent-mux-resume"
|
||||
".agents/skills/multi-agent-mux-status"
|
||||
".agents/skills/multi-agent-mux-stop"
|
||||
".agents/skills/multi-agent-mux-update"
|
||||
".agents/skills/multi-agent-mux-reset"
|
||||
".agents/hooks"
|
||||
".agents/hooks.json"
|
||||
".agents/MULTI_AGENT_RULES.md"
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# update.sh — Multi-Agent Mux (MAM) Orchestration Updater
|
||||
# ==============================================================================
|
||||
# Safely updates MAM skills, virtual environment, and docs to the latest version.
|
||||
# Preserves user configuration (.env) and local metadata/jobs database (.mam).
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_DIR=""
|
||||
FORCE=0
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-y|--yes|--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
TARGET_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TARGET_DIR" ]; then
|
||||
if [ "$(basename "$SCRIPT_DIR")" = ".mam_deploy" ]; then
|
||||
TARGET_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
else
|
||||
TARGET_DIR="$(pwd)"
|
||||
fi
|
||||
else
|
||||
if [ "$(basename "$TARGET_DIR")" = ".mam_deploy" ]; then
|
||||
TARGET_DIR="$(dirname "$TARGET_DIR")"
|
||||
fi
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
echo "❌ Error: Target directory '$TARGET_DIR' does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
TARGET_DIR="$(cd "$TARGET_DIR" && pwd)"
|
||||
fi
|
||||
|
||||
echo "===================================================================="
|
||||
echo "⚡ Starting Multi-Agent Mux (MAM) Update"
|
||||
echo "📂 Target Workspace: $TARGET_DIR"
|
||||
echo "===================================================================="
|
||||
|
||||
cd "$TARGET_DIR"
|
||||
|
||||
# 1. Verification of existing install (B-2: dual resolution)
|
||||
REMOVER=""
|
||||
for cand in ".mam_deploy/remove.sh" "remove.sh"; do
|
||||
if [ -f "$cand" ]; then
|
||||
REMOVER="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$REMOVER" ]; then
|
||||
echo "❌ Error: No MAM installation (remove.sh) found in '$TARGET_DIR'." >&2
|
||||
echo " Please run install.sh first to set up the workspace." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Request confirmation if not forced
|
||||
if [ $FORCE -eq 0 ]; then
|
||||
echo "⚠️ WARNING: This will update MAM orchestration skills, virtual environment, "
|
||||
echo " and docs to the latest version."
|
||||
echo " (Your configuration, job history, and custom skills will be preserved)."
|
||||
if [ ! -t 0 ]; then
|
||||
echo "❌ Error: Non-interactive terminal detected. Please run with -y/--yes/--force." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! read -p "❓ Proceed with update? [y/N]: " -r response; then
|
||||
response="n"
|
||||
fi
|
||||
if [[ ! "$response" =~ ^[yY](es)?$ ]]; then
|
||||
echo "❌ Update cancelled by user."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1.5 Pre-capture legacy ownership before remove.sh deletes the manifest (M-4)
|
||||
MAM_LEGACY_ENV_OWNED=0
|
||||
if [ -f ".mam/install_manifest.txt" ] && grep -Fqx ".env" ".mam/install_manifest.txt" 2>/dev/null; then
|
||||
MAM_LEGACY_ENV_OWNED=1
|
||||
fi
|
||||
export MAM_LEGACY_ENV_OWNED
|
||||
|
||||
# 2. Stage backups of user configurations and metadata to prevent deletion
|
||||
echo "💾 Backing up configuration and database..."
|
||||
HAS_ENV=0
|
||||
ENV_BACKUP_SRC=""
|
||||
ENV_BACKUP_TMP=""
|
||||
|
||||
if [ -f ".mam.env" ]; then
|
||||
HAS_ENV=1
|
||||
ENV_BACKUP_SRC=".mam.env"
|
||||
ENV_BACKUP_TMP=".mam.env.update-tmp"
|
||||
mv ".mam.env" "$ENV_BACKUP_TMP"
|
||||
elif [ -f ".env" ]; then
|
||||
HAS_ENV=1
|
||||
ENV_BACKUP_SRC=".env"
|
||||
ENV_BACKUP_TMP=".env.update-tmp"
|
||||
mv ".env" "$ENV_BACKUP_TMP"
|
||||
fi
|
||||
|
||||
HAS_MAM=0
|
||||
if [ -d ".mam" ]; then
|
||||
HAS_MAM=1
|
||||
# Copy database and jobs to temporary backup outside of .mam.
|
||||
# We do NOT move the .mam folder away so that remove.sh can still read .mam/install_manifest.txt!
|
||||
mkdir -p .mam.update-tmp
|
||||
# Copy SQLite databases and session files
|
||||
for db in .mam/agent-sessions.*; do
|
||||
if [ -f "$db" ]; then
|
||||
cp -f "$db" .mam.update-tmp/
|
||||
fi
|
||||
done
|
||||
# Copy jobs history
|
||||
if [ -d ".mam/jobs" ] && [ "$(ls -A .mam/jobs 2>/dev/null)" ]; then
|
||||
mkdir -p .mam.update-tmp/jobs
|
||||
cp -rf .mam/jobs/* .mam.update-tmp/jobs/
|
||||
fi
|
||||
# Copy delegate logs
|
||||
if [ -d ".mam/delegate_job_logs" ] && [ "$(ls -A .mam/delegate_job_logs 2>/dev/null)" ]; then
|
||||
mkdir -p .mam.update-tmp/delegate_job_logs
|
||||
cp -rf .mam/delegate_job_logs/* .mam.update-tmp/delegate_job_logs/
|
||||
fi
|
||||
# Copy manifest so we have a backup
|
||||
if [ -f ".mam/install_manifest.txt" ]; then
|
||||
cp -f .mam/install_manifest.txt .mam.update-tmp/
|
||||
fi
|
||||
# C12: Copy MAM state files across update cycle
|
||||
for st in install_state asset_hashes.txt version.txt; do
|
||||
if [ -f ".mam/$st" ]; then
|
||||
cp -f ".mam/$st" .mam.update-tmp/
|
||||
fi
|
||||
done
|
||||
if [ -d ".mam/skill-backups" ]; then
|
||||
mkdir -p .mam.update-tmp/skill-backups
|
||||
cp -rf .mam/skill-backups/* .mam.update-tmp/skill-backups/ 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Define trap to restore backup files on failure
|
||||
restore_on_failure() {
|
||||
echo "❌ Update failed. Reverting configuration and database to previous state..."
|
||||
if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then
|
||||
mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC" 2>/dev/null || true
|
||||
fi
|
||||
if [ $HAS_MAM -eq 1 ] && [ -d ".mam.update-tmp" ]; then
|
||||
# Revert to old database/jobs backup by restoring .mam directory
|
||||
rm -rf .mam 2>/dev/null || true
|
||||
mkdir -p .mam
|
||||
cp -f .mam.update-tmp/agent-sessions.* .mam/ 2>/dev/null || true
|
||||
if [ -d ".mam.update-tmp/jobs" ]; then
|
||||
cp -rf .mam.update-tmp/jobs .mam/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -d ".mam.update-tmp/delegate_job_logs" ]; then
|
||||
cp -rf .mam.update-tmp/delegate_job_logs .mam/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -f ".mam.update-tmp/install_manifest.txt" ]; then
|
||||
cp -f .mam.update-tmp/install_manifest.txt .mam/ 2>/dev/null || true
|
||||
fi
|
||||
for st in install_state asset_hashes.txt version.txt; do
|
||||
if [ -f ".mam.update-tmp/$st" ]; then
|
||||
cp -f ".mam.update-tmp/$st" .mam/ 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ -d ".mam.update-tmp/skill-backups" ]; then
|
||||
mkdir -p .mam/skill-backups
|
||||
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
|
||||
fi
|
||||
rm -rf .mam.update-tmp 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap restore_on_failure EXIT
|
||||
|
||||
# 3. Perform uninstallation of existing files
|
||||
echo "🗑️ Removing existing installation..."
|
||||
# remove.sh will run in manifest mode because .mam/install_manifest.txt is still present.
|
||||
# It will delete .agents/, documents, scripts, .venv, and .mam folder.
|
||||
bash "$REMOVER" --force "$TARGET_DIR"
|
||||
|
||||
# 4. Fetch and run the latest installer from Gitea
|
||||
echo "📥 Fetching and running the latest installer..."
|
||||
# Note: MAM_*_URL environment variables are automatically inherited by the child installer bash process.
|
||||
INSTALLER_URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}"
|
||||
if command -v curl &>/dev/null; then
|
||||
curl -fsSL "$INSTALLER_URL" | bash -s -- "$TARGET_DIR"
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -qO- "$INSTALLER_URL" | bash -s -- "$TARGET_DIR"
|
||||
else
|
||||
echo "❌ Error: Neither 'curl' nor 'wget' is available to fetch the installer." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Disable failure trap since installation succeeded
|
||||
trap - EXIT
|
||||
|
||||
# 5. Restore backups of configuration and database
|
||||
echo "🔄 Restoring configuration and database..."
|
||||
if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then
|
||||
if [ "$MAM_LEGACY_ENV_OWNED" = "1" ] && [ "$ENV_BACKUP_SRC" = ".env" ] && [ ! -f ".mam.env" ]; then
|
||||
mv -f "$ENV_BACKUP_TMP" ".mam.env"
|
||||
chmod 0600 ".mam.env" 2>/dev/null || true
|
||||
else
|
||||
mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $HAS_MAM -eq 1 ]; then
|
||||
if [ -d ".mam.update-tmp" ]; then
|
||||
# The installer created a new .mam directory with a new manifest.
|
||||
# We want to merge the old .mam database/jobs back while keeping the new manifest.
|
||||
for db in .mam.update-tmp/agent-sessions.*; do
|
||||
if [ -f "$db" ]; then
|
||||
cp -f "$db" .mam/
|
||||
fi
|
||||
done
|
||||
if [ -d ".mam.update-tmp/jobs" ] && [ "$(ls -A .mam.update-tmp/jobs 2>/dev/null)" ]; then
|
||||
mkdir -p .mam/jobs
|
||||
cp -rf .mam.update-tmp/jobs/* .mam/jobs/
|
||||
fi
|
||||
if [ -d ".mam.update-tmp/delegate_job_logs" ] && [ "$(ls -A .mam.update-tmp/delegate_job_logs 2>/dev/null)" ]; then
|
||||
mkdir -p .mam/delegate_job_logs
|
||||
cp -rf .mam.update-tmp/delegate_job_logs/* .mam/delegate_job_logs/
|
||||
fi
|
||||
# Issue #8: asset_hashes.txt and version.txt describe the tree the
|
||||
# installer just wrote, so the pre-update copies must NOT come back here -
|
||||
# restoring them made version.txt name the previous commit and remove.sh
|
||||
# treat every updated file as locally modified (spurious backups). Only
|
||||
# install_state carries history worth keeping: merge in any key the fresh
|
||||
# install did not write (e.g. gitignore_created from the original install).
|
||||
# The failure path (restore_on_failure) still puts all three back verbatim.
|
||||
if [ -f ".mam.update-tmp/install_state" ]; then
|
||||
touch .mam/install_state
|
||||
# A fresh install_state without a final newline would glue the first
|
||||
# merged key onto its last line.
|
||||
if [ -s .mam/install_state ] && [ -n "$(tail -c1 .mam/install_state)" ]; then
|
||||
printf '\n' >> .mam/install_state
|
||||
fi
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
*=*) ;;
|
||||
*) continue ;; # not a key=value line
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
[ -n "$key" ] || continue
|
||||
# exact key match, no regex interpretation of the key
|
||||
if ! awk -F= -v k="$key" '$1 == k { found = 1 } END { exit !found }' .mam/install_state; then
|
||||
printf '%s\n' "$line" >> .mam/install_state
|
||||
fi
|
||||
done < .mam.update-tmp/install_state
|
||||
fi
|
||||
if [ -d ".mam.update-tmp/skill-backups" ]; then
|
||||
mkdir -p .mam/skill-backups
|
||||
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
|
||||
fi
|
||||
rm -rf ".mam.update-tmp"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "===================================================================="
|
||||
echo "🎉 Update complete!"
|
||||
echo "===================================================================="
|
||||
@@ -470,6 +470,9 @@ def _sanitize_agent_name(name):
|
||||
def _match_agent(k, target):
|
||||
if not k or not target:
|
||||
return False
|
||||
# Issue #66: like the real herdr, answer only the registered (sanitized) name.
|
||||
if state.get("strict_agent_names"):
|
||||
return k == target
|
||||
return k == target or _sanitize_agent_name(k) == target or _sanitize_agent_name(target) == k or _sanitize_agent_name(k) == _sanitize_agent_name(target)
|
||||
|
||||
args = sys.argv[1:]
|
||||
@@ -1052,6 +1055,10 @@ elif cmd1 == "agent":
|
||||
}))
|
||||
sys.exit(0)
|
||||
else:
|
||||
if state.get("strict_agent_names"):
|
||||
sys.stderr.write(json.dumps({"error": {"code": "agent_not_found",
|
||||
"message": "agent " + name + " not found"}}) + "\\n")
|
||||
sys.exit(1)
|
||||
sys.stderr.write("Agent " + name + " not found\\n")
|
||||
sys.exit(1)
|
||||
elif cmd2 == "wait":
|
||||
@@ -1143,6 +1150,10 @@ elif cmd1 == "agent":
|
||||
print(buffer_content)
|
||||
sys.exit(0)
|
||||
else:
|
||||
if state.get("strict_agent_names"):
|
||||
sys.stderr.write(json.dumps({"error": {"code": "agent_not_found",
|
||||
"message": "agent " + name + " not found"}}) + "\\n")
|
||||
sys.exit(1)
|
||||
sys.stderr.write("Agent " + name + " not found\\n")
|
||||
sys.exit(1)
|
||||
elif cmd2 == "prompt":
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"kind": "example",
|
||||
"evidence": [
|
||||
"multi-agent-mux-create/scripts/create_session.sh:99",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:356"
|
||||
"multi-agent-mux-create/scripts/create_session.sh:364"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "HERDR_SESSION_NAME environment example"
|
||||
@@ -61,21 +61,21 @@
|
||||
{
|
||||
"skill": "multi-agent-mux-create",
|
||||
"start": 134,
|
||||
"end": 152,
|
||||
"end": 150,
|
||||
"lang": "yaml",
|
||||
"sha": "2dd608d04ae0",
|
||||
"sha": "efba73c1d7f7",
|
||||
"class": "c",
|
||||
"kind": "example",
|
||||
"evidence": [
|
||||
"multi-agent-mux-create/scripts/create_session.sh:518-545"
|
||||
"multi-agent-mux-create/scripts/create_session.sh:522-549"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "Output format: the row create_session.sh writes"
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-create",
|
||||
"start": 158,
|
||||
"end": 160,
|
||||
"start": 156,
|
||||
"end": 158,
|
||||
"lang": "bash",
|
||||
"sha": "613003708f19",
|
||||
"class": "c",
|
||||
@@ -88,23 +88,23 @@
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-create",
|
||||
"start": 204,
|
||||
"end": 208,
|
||||
"start": 202,
|
||||
"end": 206,
|
||||
"lang": "bash",
|
||||
"sha": "38836d31bf38",
|
||||
"sha": "8bfabafb0328",
|
||||
"class": "c",
|
||||
"kind": "invocation",
|
||||
"evidence": [
|
||||
"multi-agent-mux-create/scripts/worktree_prepare.sh:24",
|
||||
"multi-agent-mux-create/scripts/worktree_prepare.sh:250"
|
||||
"multi-agent-mux-create/scripts/worktree_prepare.sh:251"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "worktree_prepare.sh call"
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-create",
|
||||
"start": 231,
|
||||
"end": 253,
|
||||
"start": 229,
|
||||
"end": 251,
|
||||
"lang": "bash",
|
||||
"sha": "d90cfd2555fd",
|
||||
"class": "b",
|
||||
@@ -122,7 +122,7 @@
|
||||
"class": "c",
|
||||
"kind": "invocation",
|
||||
"evidence": [
|
||||
"multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1046-1057"
|
||||
"multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1051-1062"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "CLI reference; subcommands submit/status/list/verify/wait/logs match main()"
|
||||
@@ -175,7 +175,7 @@
|
||||
"kind": "invocation",
|
||||
"evidence": [
|
||||
"lib_py/loop/runner.py:155",
|
||||
"multi-agent-mux-create/scripts/worktree_prepare.sh:250"
|
||||
"multi-agent-mux-create/scripts/worktree_prepare.sh:251"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "--worktree/--base --review-only call pattern"
|
||||
@@ -245,7 +245,7 @@
|
||||
"class": "c",
|
||||
"kind": "example",
|
||||
"evidence": [
|
||||
"multi-agent-mux-monitor/scripts/reconcile.sh:708"
|
||||
"multi-agent-mux-monitor/scripts/reconcile.sh:705"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "drift B example"
|
||||
@@ -259,7 +259,7 @@
|
||||
"class": "c",
|
||||
"kind": "example",
|
||||
"evidence": [
|
||||
"multi-agent-mux-monitor/scripts/reconcile.sh:735"
|
||||
"multi-agent-mux-monitor/scripts/reconcile.sh:732"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "drift C example (indented fence)"
|
||||
@@ -385,7 +385,7 @@
|
||||
"class": "c",
|
||||
"kind": "example",
|
||||
"evidence": [
|
||||
"multi-agent-mux-status/scripts/status.sh:411"
|
||||
"multi-agent-mux-status/scripts/status.sh:410"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "table output example"
|
||||
@@ -393,15 +393,15 @@
|
||||
{
|
||||
"skill": "multi-agent-mux-status",
|
||||
"start": 74,
|
||||
"end": 106,
|
||||
"end": 105,
|
||||
"lang": "json",
|
||||
"sha": "cfea3a10dc8d",
|
||||
"sha": "8e55751f5e8d",
|
||||
"class": "c",
|
||||
"kind": "example",
|
||||
"evidence": [
|
||||
"multi-agent-mux-status/scripts/status.sh:31-36",
|
||||
"multi-agent-mux-status/scripts/status.sh:209-232",
|
||||
"multi-agent-mux-monitor/scripts/reconcile.sh:816"
|
||||
"multi-agent-mux-status/scripts/status.sh:209-231",
|
||||
"multi-agent-mux-monitor/scripts/reconcile.sh:813"
|
||||
],
|
||||
"mismatch": [
|
||||
"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",
|
||||
"start": 42,
|
||||
"end": 55,
|
||||
"start": 41,
|
||||
"end": 54,
|
||||
"lang": "bash",
|
||||
"sha": "301c347ff881",
|
||||
"class": "c",
|
||||
"kind": "invocation",
|
||||
"evidence": [
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:1-33"
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:1-36"
|
||||
],
|
||||
"mismatch": [
|
||||
"T2 (fixed): the --purge-conversation example lacked --yes and would exit 3"
|
||||
@@ -426,22 +426,22 @@
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-stop",
|
||||
"start": 81,
|
||||
"end": 85,
|
||||
"start": 82,
|
||||
"end": 86,
|
||||
"lang": "-",
|
||||
"sha": "a973676cb2d1",
|
||||
"class": "c",
|
||||
"kind": "diagram",
|
||||
"evidence": [
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:183-197"
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:250-264"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "state machine"
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-stop",
|
||||
"start": 102,
|
||||
"end": 104,
|
||||
"start": 103,
|
||||
"end": 105,
|
||||
"lang": "bash",
|
||||
"sha": "4189d73d6896",
|
||||
"class": "c",
|
||||
@@ -454,8 +454,8 @@
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-stop",
|
||||
"start": 127,
|
||||
"end": 145,
|
||||
"start": 128,
|
||||
"end": 146,
|
||||
"lang": "bash",
|
||||
"sha": "bcaa2a03484e",
|
||||
"class": "b",
|
||||
@@ -463,6 +463,34 @@
|
||||
"evidence": [],
|
||||
"mismatch": null,
|
||||
"note": "post-stop checks; no script offers them as a command - stage 3"
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-update",
|
||||
"start": 17,
|
||||
"end": 19,
|
||||
"lang": "bash",
|
||||
"sha": "63f73ade58d3",
|
||||
"class": "c",
|
||||
"kind": "invocation",
|
||||
"evidence": [
|
||||
"multi-agent-mux-update/scripts/update.sh:3"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "entry point call (Issue #72)"
|
||||
},
|
||||
{
|
||||
"skill": "multi-agent-mux-reset",
|
||||
"start": 17,
|
||||
"end": 19,
|
||||
"lang": "bash",
|
||||
"sha": "3e9ea3c85bb0",
|
||||
"class": "c",
|
||||
"kind": "invocation",
|
||||
"evidence": [
|
||||
"multi-agent-mux-reset/scripts/reset.sh:6"
|
||||
],
|
||||
"mismatch": null,
|
||||
"note": "entry point call (Issue #78)"
|
||||
}
|
||||
],
|
||||
"removed": [
|
||||
@@ -476,11 +504,11 @@
|
||||
"evidence": [
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:111-130",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:133-144",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:147-158",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:172-197",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:245",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:249",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:255-258",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:147-170",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:188-213",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:266",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:270",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh:276-279",
|
||||
"lib_py/agents/adapters/claude.py:109"
|
||||
],
|
||||
"mismatch": [
|
||||
@@ -503,9 +531,9 @@
|
||||
"base_sha": "a7ed3e902cae",
|
||||
"class": "a",
|
||||
"evidence": [
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:98",
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:139-142",
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:183-187"
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:101",
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:128-198",
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh:250-254"
|
||||
],
|
||||
"mismatch": [
|
||||
"T1: the doc offered --force-no-yaml, which no script accepts"
|
||||
@@ -546,9 +574,9 @@
|
||||
"evidence": [
|
||||
"multi-agent-mux-create/scripts/create_session.sh:166-168",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:207",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:259-274",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:361-398",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:410-412"
|
||||
"multi-agent-mux-create/scripts/create_session.sh:263-278",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:369-406",
|
||||
"multi-agent-mux-create/scripts/create_session.sh:418-420"
|
||||
],
|
||||
"mismatch": [
|
||||
"C1: an existing session is exit 1 in the doc, exit 3 in the script",
|
||||
@@ -568,7 +596,7 @@
|
||||
"base_sha": "f71be0f26fb8",
|
||||
"class": "a",
|
||||
"evidence": [
|
||||
"multi-agent-mux-create/scripts/create_session.sh:491-610"
|
||||
"multi-agent-mux-create/scripts/create_session.sh:494-612"
|
||||
],
|
||||
"mismatch": [],
|
||||
"replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace <path> --agent <agent> --role <role>",
|
||||
@@ -596,10 +624,12 @@
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"other"
|
||||
],
|
||||
"passthrough": {
|
||||
"1": "multi-agent-mux-stop/scripts/stop_session.sh:322"
|
||||
"1": "multi-agent-mux-stop/scripts/stop_session.sh:389"
|
||||
}
|
||||
},
|
||||
"multi-agent-mux-create/scripts/create_session.sh": {
|
||||
@@ -617,14 +647,27 @@
|
||||
"other"
|
||||
],
|
||||
"passthrough": {
|
||||
"4": "multi-agent-mux-create/scripts/create_session.sh:550"
|
||||
"4": "multi-agent-mux-create/scripts/create_session.sh:554"
|
||||
}
|
||||
},
|
||||
"multi-agent-mux-reset/scripts/reset.sh": {
|
||||
"skill": "multi-agent-mux-reset",
|
||||
"exit_codes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"other"
|
||||
],
|
||||
"passthrough": {}
|
||||
}
|
||||
},
|
||||
"prose_mismatches": [
|
||||
"C4: create named a nonexistent `agent-sessions-yaml-edit` script; removed (create_session.sh writes the row)",
|
||||
"C5: create's cmd_full table (claude = `claude`) disagreed with adapter spawn-spec; replaced by \"decided by spawn-spec\"",
|
||||
"C6: create_session.sh header lists exit 4 \"YAML append failure\"; there is no literal `exit 4`, but the YAML write raises SystemExit(4) for a duplicate running row (create_session.sh:515) and it passes through, so 4 IS a real exit code (the plan assumed it was not)",
|
||||
"C6: create_session.sh header lists exit 4 \"YAML append failure\"; there is no literal `exit 4`, but the YAML write raises SystemExit(4) for a duplicate running row (create_session.sh:516) and it passes through, so 4 IS a real exit code (the plan assumed it was not)",
|
||||
"R6: resume's \"The script above re-passes them\" and \"as done in Workflow above\" pointed at the removed block; reworded (same for create's Verification note)",
|
||||
"R7 (review 08e1f1b2): stop's table said exit 1 is always pre-stop; the registry update after the kill chain also raises SystemExit(1) (stop_session.sh:310) when the row disappeared"
|
||||
],
|
||||
|
||||
@@ -48,7 +48,7 @@ class TestDeployLayout(unittest.TestCase):
|
||||
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".agents", "skills", "lib.sh")))
|
||||
|
||||
def test_td6_td7_td8_mam_deploy_layout_and_removal(self):
|
||||
"""T-D6~D8: Verify remove.sh/update.sh installed in .mam_deploy and work properly."""
|
||||
"""T-D6~D8: Verify remove.sh installed in .mam_deploy (update.sh not, Issue #72) and works properly."""
|
||||
subprocess.run(
|
||||
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
|
||||
env=self.env,
|
||||
@@ -59,7 +59,7 @@ class TestDeployLayout(unittest.TestCase):
|
||||
remover = os.path.join(self.test_dir, ".mam_deploy", "remove.sh")
|
||||
updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh")
|
||||
self.assertTrue(os.path.exists(remover))
|
||||
self.assertTrue(os.path.exists(updater))
|
||||
self.assertFalse(os.path.exists(updater))
|
||||
self.assertTrue(os.access(remover, os.X_OK))
|
||||
|
||||
# T-D8: Execution from inside .mam_deploy directory targets parent workspace
|
||||
@@ -129,17 +129,29 @@ class TestDeployLayout(unittest.TestCase):
|
||||
self.assertTrue(os.path.exists(backups_dir))
|
||||
|
||||
def test_td27_td28_update_preserves_mam_state(self):
|
||||
"""T-D27~D28: Verify update.sh preserves .mam state files across cycles (B-5 gate)."""
|
||||
"""T-D27~D28: Verify the update skill preserves .mam state files across cycles (B-5 gate)."""
|
||||
subprocess.run(
|
||||
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
|
||||
env=self.env,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Run update.sh
|
||||
updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh")
|
||||
res = subprocess.run(["bash", updater, "--force"], cwd=self.test_dir, env=self.env, capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"update.sh failed: {res.stderr}")
|
||||
# Run the update skill (Issue #72: .mam_deploy/update.sh is gone)
|
||||
updater = os.path.join(self.test_dir, ".agents", "skills", "multi-agent-mux-update", "scripts", "update.sh")
|
||||
env = {k: v for k, v in self.env.items() if not k.startswith("HERDR_")
|
||||
and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR")}
|
||||
env["MAM_NO_SUBSCRIBER_DAEMON"] = "1"
|
||||
# The update lists the live herdr agents; never ask a real server here.
|
||||
fake = os.path.join(self.temp_dir, "fake-herdr-bin")
|
||||
os.makedirs(fake)
|
||||
with open(os.path.join(fake, "herdr"), "w") as f:
|
||||
f.write('#!/bin/sh\necho \'{"sessions": []}\'\n')
|
||||
os.chmod(os.path.join(fake, "herdr"), 0o755)
|
||||
env["PATH"] = fake + ":" + env["PATH"]
|
||||
res = subprocess.run(["bash", updater, "--yes"], cwd=self.test_dir, env=env, capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(res.returncode, 0, f"the update skill failed: {res.stdout}{res.stderr}")
|
||||
self.assertFalse(os.path.exists(os.path.join(self.test_dir, ".mam_deploy", "update.sh")))
|
||||
|
||||
# Verify asset_hashes.txt and version.txt still exist
|
||||
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".mam", "asset_hashes.txt")))
|
||||
|
||||
@@ -44,7 +44,7 @@ def _sibling(main: Path, state_file):
|
||||
before = _rows(main)
|
||||
since = len(_raw_calls(state_file))
|
||||
wt = _wt(main)
|
||||
res = _prepare(main, wt, "--creator-agent", "claude", "--herdr-session", HS)
|
||||
res = _prepare(main, wt, "--creator-agent", "claude", "--herdr-session", HS, "--no-onboard")
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
return wt, since, before
|
||||
|
||||
@@ -204,7 +204,7 @@ def test_a5b_a_stale_last_round_is_not_accepted(wt_repo, monkeypatch, tmp_path,
|
||||
def test_a6_row_recovery_and_resume_only_at_the_original_cwd(ctl, mock_herdr, mock_agents):
|
||||
main = ctl
|
||||
wt = _wt(main)
|
||||
res = _prepare(main, wt, "--creator-agent", "claude")
|
||||
res = _prepare(main, wt, "--creator-agent", "claude", "--no-onboard")
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(main)
|
||||
name, binding = row["name"], row["control_plane"]
|
||||
|
||||
@@ -419,7 +419,7 @@ def test_w6_remove_plain_and_with_stop(ctl, mock_herdr, mock_agents):
|
||||
assert os.path.realpath(wt1) not in _worktrees(main)
|
||||
assert _git(main, "show-ref", "--verify", "--quiet", f"refs/heads/b-{wt1.name}", check=False).returncode == 0
|
||||
|
||||
res = _prepare(main, wt2, "--creator-agent", "claude")
|
||||
res = _prepare(main, wt2, "--creator-agent", "claude", "--no-onboard")
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
name = [r for r in _rows(main) if os.path.realpath(r["pane"]["cwd"]) == os.path.realpath(wt2)][0]["name"]
|
||||
res = _run(main, REM, "--path", str(wt2), "--stop")
|
||||
|
||||
@@ -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]):
|
||||
assert f"{k}={v}" in flags, flags
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
(only a directory that is itself *-shim is skipped), so the base is
|
||||
that commit."""
|
||||
(only a directory that is itself *-shim is skipped). Issue #71 changed it
|
||||
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__)))
|
||||
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],
|
||||
capture_output=True, text=True, cwd=repo)
|
||||
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):
|
||||
"""What lib.sh writes into the shim: the sanitize function it declares in
|
||||
(Issue #71), then the heredoc."""
|
||||
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
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "cat <<'EOF' > \"$tmp_file\"":
|
||||
if line.strip() == "cat <<'EOF'":
|
||||
start = i + 1
|
||||
break
|
||||
assert start is not None, "shim heredoc not found in lib.sh"
|
||||
for j in range(start, len(lines)):
|
||||
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")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
res, published, yaml_text = _run_rollback(mam_sandbox, kill_rc=1, still_alive=True)
|
||||
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 "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
|
||||
|
||||
@@ -59,6 +59,8 @@ INT_KNOBS = {
|
||||
# once) and the wrapper's registry wait (0: decide at once - clear or busy)
|
||||
"MAM_ONBOARD_WAIT_S": (1, "600"),
|
||||
"MAM_DISPATCH_WAIT_S": (0, "600"),
|
||||
# Issue #72: how long the update waits for the monitor to stop after SIGTERM
|
||||
"MAM_UPDATE_MONITOR_STOP_S": (1, "30"),
|
||||
# delegate_wait_started's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh)
|
||||
"delegate_wait_started:ack_s": (0, "180"),
|
||||
# wrapper flags
|
||||
@@ -106,6 +108,7 @@ NOT_ARITHMETIC = {
|
||||
# sleep's fractional seconds are its meaning here (default 0.5): mam_int would refuse them
|
||||
"SKS_QUIESCENT_INTERVAL": "sleep, fractional by default (0.5) - left raw",
|
||||
"DELEGATE_JOB_ACK_S": "create_session.sh -> delegate_wait_started, which runs mam_int on it",
|
||||
"MAM_ID_POST_DISCOVERED": "string compare (adapter fact from lib_py.agents facts)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -115,8 +115,8 @@ def test_inventory_matches_skill_md(skill):
|
||||
f"actual (start, end, lang, sha): {json.dumps(actual)}")
|
||||
|
||||
|
||||
def test_inventory_covers_eight_skills():
|
||||
assert len(SKILL_NAMES) == 8
|
||||
def test_inventory_covers_nine_skills():
|
||||
assert len(SKILL_NAMES) == 10 # Issue #78: + multi-agent-mux-reset
|
||||
assert {b["skill"] for b in DATA["blocks"]} == set(SKILL_NAMES)
|
||||
|
||||
|
||||
@@ -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`
|
||||
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-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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
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 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"])
|
||||
|
||||
@@ -96,7 +96,7 @@ def test_the_1x_capture_still_reads_empty():
|
||||
|
||||
@pytest.mark.parametrize("name", HOME_SCREENS)
|
||||
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))
|
||||
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 ("srv-x", "orphan-a-agy") in probes
|
||||
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
|
||||
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 json.loads(same.read_text())["owner_pid"] == live.pid
|
||||
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")
|
||||
assert wait_same() == 1
|
||||
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()
|
||||
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 name_b not in _read_rows(mam_sandbox)
|
||||
assert name_b not in json.loads(Path(mock_herdr).read_text()).get("agents", {})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Issue #66: stall_watch calls herdr with the sanitized agent name.
|
||||
|
||||
herdr registers an agent under `sanitize_herdr_agent_name(session)` (lower
|
||||
case, at most 32 characters). The watcher gets the original session name from
|
||||
the wrapper, so it must convert it for every herdr call while its logs keep
|
||||
the original name and the agent kind is still read off the original suffix.
|
||||
|
||||
The harness is the one of test_issue30_stall_watch (a real watcher process, a
|
||||
real job, the conftest mock herdr), with the mock switched to
|
||||
`strict_agent_names`: like the real herdr it answers an agent only under its
|
||||
registered (sanitized) name, so the original name gets `agent_not_found`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_issue30_stall_watch as t30 # noqa: E402
|
||||
|
||||
from lib_py.agents.sanitize import sanitize_herdr_agent_name # noqa: E402
|
||||
|
||||
LONG = "creator-claude-fix-issue-99-long-branch" # 39 characters
|
||||
UPPER = "Creator-Claude-T"
|
||||
PLAIN = "creator-claude-t" # needs no conversion
|
||||
LONG_CODEX = "mam-wt-issue-66-very-long-creator-codex" # kind only in the original suffix
|
||||
|
||||
DIALOG = "\n Dangerous rm operation detected. Do you want to proceed?\n 1. Yes\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def strict_box(mam_sandbox, mock_herdr):
|
||||
state = json.loads(mock_herdr.read_text())
|
||||
state["strict_agent_names"] = True
|
||||
mock_herdr.write_text(json.dumps(state, indent=2))
|
||||
reg = mam_sandbox / ".mam" / "jobs"
|
||||
return mock_herdr, reg, t30._env(mam_sandbox, MAM_STALL_IDLE_S=2)
|
||||
|
||||
|
||||
def _agent_calls(state_file: Path) -> list:
|
||||
return [c for c in json.loads(state_file.read_text())["calls"]
|
||||
if c[:1] == ["agent"] and c[1:2] in (["get"], ["read"], ["prompt"], ["wait"])]
|
||||
|
||||
|
||||
def test_the_names_change_and_the_strict_mock_answers_only_the_sanitized_one(strict_box):
|
||||
state, _, env = strict_box
|
||||
assert len(LONG) > 32 and sanitize_herdr_agent_name(LONG) != LONG
|
||||
assert sanitize_herdr_agent_name(UPPER) == PLAIN
|
||||
assert sanitize_herdr_agent_name(PLAIN) == PLAIN
|
||||
for name in (LONG, UPPER):
|
||||
herdr_name = sanitize_herdr_agent_name(name)
|
||||
t30._agent(state, herdr_name, "claude", 4242, "idle", "Ready")
|
||||
for cmd in (["get"], ["read"], ["wait", "--until", "idle"], ["prompt"]):
|
||||
argv = ["herdr", "agent", cmd[0], name] + cmd[1:] + (["hi"] if cmd == ["prompt"] else [])
|
||||
res = subprocess.run(argv, capture_output=True, text=True, env=env)
|
||||
assert res.returncode == 1, (argv, res.stdout)
|
||||
assert json.loads(res.stderr)["error"]["code"] == "agent_not_found", argv
|
||||
argv[3] = herdr_name
|
||||
assert subprocess.run(argv, capture_output=True, text=True, env=env).returncode == 0, argv
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [LONG, UPPER, PLAIN])
|
||||
def test_an_idle_agent_is_nudged_through_the_sanitized_name(strict_box, target):
|
||||
state, reg, env = strict_box
|
||||
herdr_name = sanitize_herdr_agent_name(target)
|
||||
jid = t30._job(env, reg)
|
||||
agent = t30._proc("exec sleep 300")
|
||||
try:
|
||||
t30._agent(state, herdr_name, "claude", agent.pid, "idle", t30._pane("claude"))
|
||||
w = t30._watch(env, reg, jid, target, "claude")
|
||||
assert t30._until(lambda: t30._prompts(state))
|
||||
time.sleep(2)
|
||||
assert t30._details(reg, jid) == ["stalled:idle"]
|
||||
calls = _agent_calls(state)
|
||||
assert {c[1] for c in calls} == {"get", "read", "prompt", "wait"}, calls
|
||||
assert all(c[2] == herdr_name for c in calls), calls
|
||||
assert len(t30._prompts(state)) == 1
|
||||
out = t30._finish(env, reg, jid, w)
|
||||
assert f"agent {target} idle for" in out
|
||||
finally:
|
||||
t30._kill(agent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [LONG, UPPER])
|
||||
def test_a_blocked_agent_is_reported_with_the_original_name(strict_box, target):
|
||||
state, reg, env = strict_box
|
||||
herdr_name = sanitize_herdr_agent_name(target)
|
||||
jid = t30._job(env, reg)
|
||||
agent = t30._proc("exec sleep 300")
|
||||
try:
|
||||
t30._agent(state, herdr_name, "claude", agent.pid, "blocked", t30._pane("claude"), detection=DIALOG)
|
||||
w = t30._watch(env, reg, jid, target, "claude")
|
||||
assert t30._until(lambda: t30._details(reg, jid))
|
||||
time.sleep(2)
|
||||
assert t30._details(reg, jid) == ["stalled:blocked Dangerous rm operation detected. Do you want to proceed?"]
|
||||
calls = _agent_calls(state)
|
||||
assert calls and all(c[2] == herdr_name for c in calls), calls
|
||||
assert not t30._prompts(state)
|
||||
out = t30._finish(env, reg, jid, w)
|
||||
assert f"agent {target} is blocked" in out
|
||||
finally:
|
||||
t30._kill(agent)
|
||||
|
||||
|
||||
def test_the_kind_of_a_long_name_comes_from_its_original_suffix(strict_box):
|
||||
"""No usable --agent: the kind comes from the session name. Read off the
|
||||
sanitized name (a hash at the end) it would be unknown, and the nudge would
|
||||
be withheld as `input_check_unavailable`."""
|
||||
state, reg, env = strict_box
|
||||
herdr_name = sanitize_herdr_agent_name(LONG_CODEX)
|
||||
assert not herdr_name.endswith("-codex")
|
||||
jid = t30._job(env, reg)
|
||||
agent = t30._proc("exec sleep 300")
|
||||
try:
|
||||
t30._agent(state, herdr_name, "codex", agent.pid, "idle", t30._pane("codex"))
|
||||
w = t30._watch(env, reg, jid, LONG_CODEX, "")
|
||||
assert t30._until(lambda: t30._prompts(state))
|
||||
time.sleep(2)
|
||||
assert t30._details(reg, jid) == ["stalled:idle"]
|
||||
assert all(c[2] == herdr_name for c in _agent_calls(state))
|
||||
t30._finish(env, reg, jid, w)
|
||||
finally:
|
||||
t30._kill(agent)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Issue #68: worktree_prepare.sh onboards by default (--no-onboard skips), and
|
||||
create_session.sh --no-onboard warns for post-discovered-id agents (adapter
|
||||
id_lifecycle == POST_DISCOVERED).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||
if str(SKILLS) not in sys.path:
|
||||
sys.path.insert(0, str(SKILLS))
|
||||
|
||||
from lib_py.agents.base import POST_DISCOVERED # noqa: E402
|
||||
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||
from test_issue21_phase2_loop_a import _prepare, _rows, _wt, ctl # noqa: E402,F401
|
||||
from test_issue63_same_cwd_ids import _mock_herdr_agent, _read_rows, _row, _write_rows # noqa: E402
|
||||
|
||||
ALL_AGENTS = ("claude", "agy", "hermes", "grok", "opencode", "codex")
|
||||
EXPECTED_POST_DISCOVERED = {
|
||||
"agy": 1,
|
||||
"codex": 1,
|
||||
"hermes": 1,
|
||||
"opencode": 1,
|
||||
"claude": 0,
|
||||
"grok": 0,
|
||||
}
|
||||
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
|
||||
|
||||
|
||||
def _norm_uuid(text: str) -> str:
|
||||
return UUID_RE.sub("<UUID>", text)
|
||||
|
||||
|
||||
def _warn_lines(stderr: str) -> list[str]:
|
||||
return [l for l in stderr.splitlines() if l.startswith("WARNING: --no-onboard:")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ALL_AGENTS)
|
||||
def test_facts_expose_post_discovered(agent):
|
||||
env = dict(os.environ, PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}")
|
||||
res = subprocess.run(
|
||||
[sys.executable, "-m", "lib_py.agents", "facts", agent],
|
||||
capture_output=True, text=True, check=True, env=env,
|
||||
)
|
||||
eval_res = subprocess.run(
|
||||
["bash", "-c", f"set -euo pipefail; eval {shlex.quote(res.stdout)}; echo \"$MAM_ID_POST_DISCOVERED\""],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
val = int(eval_res.stdout.strip())
|
||||
adapter = get_adapter(agent)
|
||||
assert val == (1 if adapter.id_lifecycle == POST_DISCOVERED else 0)
|
||||
assert val == EXPECTED_POST_DISCOVERED[agent]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ALL_AGENTS)
|
||||
def test_no_onboard_warning_dry_run(mam_sandbox, mock_herdr, mock_agents, monkeypatch, agent):
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-key")
|
||||
ws = mam_sandbox / f"ws-dry-{agent}"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
create_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
res = subprocess.run(
|
||||
["bash", str(create_sh), "--dry-run", "--no-onboard", "--workspace", str(ws),
|
||||
"--agent", agent, "--role", "creator", "--session", f"t68-{agent}"],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=60,
|
||||
)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
warns = _warn_lines(res.stderr)
|
||||
assert len(warns) == EXPECTED_POST_DISCOVERED[agent], res.stderr
|
||||
if warns:
|
||||
assert "Issue #63" in warns[0] and agent in warns[0]
|
||||
|
||||
# Control: --dry-run with default onboarding emits 0 warning lines for all agents
|
||||
ctl_res = subprocess.run(
|
||||
["bash", str(create_sh), "--dry-run", "--workspace", str(ws),
|
||||
"--agent", agent, "--role", "creator", "--session", f"t68-ctl-{agent}"],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=60,
|
||||
)
|
||||
assert ctl_res.returncode == 0, ctl_res.stdout + ctl_res.stderr
|
||||
assert _warn_lines(ctl_res.stderr) == [], ctl_res.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ALL_AGENTS)
|
||||
def test_no_onboard_warning_keeps_exit_and_row(mam_sandbox, mock_herdr, mock_agents, monkeypatch, agent):
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-key")
|
||||
ws = mam_sandbox / f"ws-real-{agent}"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
session_name = f"t68-{agent}"
|
||||
create_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
lib_sh = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
ws_slug = subprocess.run(
|
||||
["bash", "-c", f'source "{lib_sh}" && derive_workspace_slug "{ws}"'],
|
||||
capture_output=True, text=True, check=True, cwd=str(mam_sandbox),
|
||||
).stdout.strip()
|
||||
|
||||
res = subprocess.run(
|
||||
["bash", str(create_sh), "--no-onboard", "--workspace", str(ws),
|
||||
"--agent", agent, "--role", "creator", "--session", session_name],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=120,
|
||||
)
|
||||
# 1. Exit code
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
|
||||
# 2. Stable row fields (checked BEFORE warning assertion so base b9c828f fails only at step 3)
|
||||
rows = _read_rows(mam_sandbox)
|
||||
assert session_name in rows
|
||||
row = rows[session_name]
|
||||
pre_assigned = agent in ("claude", "grok")
|
||||
expected_cmd_full = _norm_uuid(
|
||||
get_adapter(agent).spawn_spec(
|
||||
str(mock_agents / agent),
|
||||
"00000000-0000-0000-0000-000000000000" if pre_assigned else "",
|
||||
)
|
||||
)
|
||||
expected_source = "assigned" if pre_assigned else ("pending-discovery" if agent in ("opencode", "codex") else None)
|
||||
expected_verified = False if agent in ("claude", "grok", "opencode", "codex") else None
|
||||
expected_vis = "assigned (awaiting first message)" if pre_assigned else "unverified"
|
||||
expected_cp = {
|
||||
"workspace_root": os.path.realpath(mam_sandbox),
|
||||
"agent_sessions_yaml": os.path.realpath(mam_sandbox / ".mam" / "agent-sessions.yaml"),
|
||||
"mam_jobs_dir": os.path.realpath(mam_sandbox / ".mam" / "jobs"),
|
||||
"delegate_job_logs_dir": os.path.realpath(mam_sandbox / ".mam" / "delegate_job_logs"),
|
||||
}
|
||||
|
||||
assert row["name"] == session_name
|
||||
assert row["status"] == "running"
|
||||
assert row["role"] == "creator"
|
||||
assert row["delegate_job_id"] is None
|
||||
assert row["herdr_server"] == ws_slug
|
||||
assert row["herdr_workspace"] == ws_slug.removeprefix("mam-")
|
||||
assert row["pane"]["cmd"] == agent
|
||||
assert _norm_uuid(row["pane"]["cmd_full"]) == expected_cmd_full
|
||||
assert os.path.realpath(row["pane"]["cwd"]) == os.path.realpath(ws)
|
||||
assert row.get("session_id_source") == expected_source
|
||||
assert row.get("session_id_verified") == expected_verified
|
||||
assert row["last_visible_status"] == expected_vis
|
||||
assert row["control_plane"] == expected_cp
|
||||
|
||||
# 3. Warning line count
|
||||
warns = _warn_lines(res.stderr)
|
||||
assert len(warns) == EXPECTED_POST_DISCOVERED[agent], res.stderr
|
||||
if warns:
|
||||
assert "Issue #63" in warns[0] and agent in warns[0]
|
||||
|
||||
|
||||
def test_no_onboard_warning_precedes_a_later_refusal(mam_sandbox, mock_herdr, mock_agents):
|
||||
ws = mam_sandbox / "c68-ws"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
epoch_a = int(time.time()) - 100
|
||||
name_a, name_b = "c68-ws-creator-agy", "c68-ws-reviewer-agy"
|
||||
create_sh = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
|
||||
holder = subprocess.Popen(["sleep", "120"])
|
||||
try:
|
||||
_write_rows(mam_sandbox, [_row(name_a, "agy", ws, epoch_a, pid=holder.pid)])
|
||||
_mock_herdr_agent(mock_herdr, name_a, ws, holder.pid, epoch_a, pane_id="w1:p_a")
|
||||
|
||||
env0 = dict(os.environ, MAM_ID_ORDER_WAIT_S="0")
|
||||
res = subprocess.run(
|
||||
["bash", str(create_sh), "--workspace", str(ws), "--agent", "agy",
|
||||
"--role", "reviewer", "--session", name_b, "--no-onboard"],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), env=env0, timeout=120,
|
||||
)
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
warns = _warn_lines(res.stderr)
|
||||
assert len(warns) == 1, res.stderr
|
||||
assert "Issue #63" in warns[0]
|
||||
assert name_a in res.stderr
|
||||
assert res.stderr.index("WARNING: --no-onboard:") < res.stderr.index(name_a), res.stderr
|
||||
finally:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"opts,expect_onboard",
|
||||
[
|
||||
([], True),
|
||||
(["--onboard"], True),
|
||||
(["--no-onboard"], False),
|
||||
(["--no-onboard", "--onboard"], True),
|
||||
],
|
||||
)
|
||||
def test_prepare_forwards_onboarding_choice(ctl, opts, expect_onboard):
|
||||
main = ctl
|
||||
args_file = main / "captured_create_args.txt"
|
||||
stub = main / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
stub.write_text(f'#!/usr/bin/env bash\nprintf "%s\\n" "$*" > "{args_file}"\nexit 0\n')
|
||||
wt = _wt(main, "fwd")
|
||||
try:
|
||||
res = _prepare(main, wt, "--creator-agent", "claude", *opts)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
tokens = args_file.read_text().split()
|
||||
if expect_onboard:
|
||||
assert "--onboard" in tokens, tokens
|
||||
assert "--no-onboard" not in tokens, tokens
|
||||
else:
|
||||
assert "--no-onboard" in tokens, tokens
|
||||
assert "--onboard" not in tokens, tokens
|
||||
finally:
|
||||
subprocess.run(["git", "worktree", "remove", "--force", str(wt)], cwd=str(main), capture_output=True)
|
||||
|
||||
|
||||
def test_prepare_onboards_by_default_end_to_end(ctl, mock_herdr, mock_agents):
|
||||
main = ctl
|
||||
wt = _wt(main, "onboard-def")
|
||||
try:
|
||||
res = _prepare(main, wt, "--creator-agent", "claude")
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(main)
|
||||
jid = row["delegate_job_id"]
|
||||
assert jid, row
|
||||
job_path = main / ".mam" / "jobs" / f"{jid}.json"
|
||||
assert job_path.is_file(), f"missing job record {job_path}"
|
||||
rec = json.loads(job_path.read_text())
|
||||
assert rec["status"] == "completed", rec
|
||||
assert f"onboarding completed: job {jid}" in res.stdout, res.stdout
|
||||
finally:
|
||||
subprocess.run(["git", "worktree", "remove", "--force", str(wt)], cwd=str(main), capture_output=True)
|
||||
|
||||
|
||||
def test_prepare_no_onboard_sends_none(ctl, mock_herdr, mock_agents):
|
||||
main = ctl
|
||||
wt = _wt(main, "no-onboard")
|
||||
try:
|
||||
res = _prepare(main, wt, "--creator-agent", "claude", "--no-onboard")
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
(row,) = _rows(main)
|
||||
assert row["delegate_job_id"] is None, row
|
||||
jobs_dir = main / ".mam" / "jobs"
|
||||
if jobs_dir.is_dir():
|
||||
for jf in jobs_dir.glob("*.json"):
|
||||
rec = json.loads(jf.read_text())
|
||||
assert rec.get("agent_session") != f"herdr:{row['name']}", rec
|
||||
finally:
|
||||
subprocess.run(["git", "worktree", "remove", "--force", str(wt)], cwd=str(main), capture_output=True)
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user