fix(stall_watch): herdr 를 변환된 에이전트 이름으로 부른다 (Issue #66) #70

Merged
Godopu merged 2 commits from fix/issue-66 into main 2026-10-25 05:07:20 +00:00
27 changed files with 540 additions and 31 deletions

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-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-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-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]
+1 -1
View File
@@ -29,7 +29,7 @@ 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.10.1"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
+1 -1
View File
@@ -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.10.1"
CORE_MODULES: Sequence[str] = (
# Standard library modules
+10 -5
View File
@@ -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
@@ -156,6 +157,9 @@ class HerdrSource(object):
def __init__(self, target, agent, session=None):
self.target, self.session = target, session or None
# herdr knows the agent only by its sanitized name (#66); the kind is
# read off the original name, whose suffix a long name's hash replaces.
self.name = sanitize_herdr_agent_name(target)
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 +180,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 +203,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."""
@@ -222,6 +226,7 @@ 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
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 +328,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 +430,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)
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos, windows]
+1 -1
View File
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -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.10.1
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.10.1](https://img.shields.io/badge/version-v4.10.1-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -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.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.10.1](https://img.shields.io/badge/version-v4.10.1-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.10.0
Applies to: MAM v4.10.1
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+1 -1
View File
@@ -2,7 +2,7 @@
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.10.1](https://img.shields.io/badge/version-v4.10.1-blue.svg)](VERSIONS.md)
---
+1 -1
View File
@@ -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.
[![Version: v4.10.0](https://img.shields.io/badge/version-v4.10.0-blue.svg)](VERSIONS.md)
[![Version: v4.10.1](https://img.shields.io/badge/version-v4.10.1-blue.svg)](VERSIONS.md)
---
+27 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.10.0`
- **최신 릴리스 일시**: 2026-10-23 (KST)
- **프레임워크 버전**: `v4.10.1`
- **최신 릴리스 일시**: 2026-10-25 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -24,23 +24,39 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.0`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.10.1`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`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.10.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.10.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.10.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.10.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.10.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.10.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.10.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.10.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `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.
+11
View File
@@ -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":
@@ -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)