release(v4.8.6): Issue #17 item 4 - archive PR #51 reviews and bump the lockstep version

Merge-workspace review: round 1 (@518beda) claude agy grok PASS, codex
NOT PASS (finalize wait measured on the wall clock); fixed in 4c86107.
Round 2 delta (@4c86107) claude agy grok PASS; codex (24b21b9c, after
817d1650 closed deadline_timeout with no report) found R1 closed and no
new defect, but withheld PASS only because its sandbox could not bind
the suite's loopback broker. The same two-file suite run here at
4c86107: 278 passed. Full suite at 518beda on macOS / Python 3.9:
1711 passed / 1 failed / 38 skipped; the failure
(test_issue45_restore, herdr 0.9.3 cwd restore) also fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-08 01:37:48 +09:00
1 parent 4c86107846
commit 8fde39be33
24 files changed
+735 -26

No files matched your search

@@ -0,0 +1,35 @@
# 리뷰 리포트 — Job b0c15e45 (PR #51 2회차 델타, 518beda..4c86107)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T155310Z-56d5a7 / base 518beda / head 4c86107 / tree e6f67f6 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
- 범위(항목 4): lib_py/delegation/wrapper.py `_sleep`(+6/−3, 주석 포함)과 테스트 1개 추가. 래퍼 bash·문서·버전 변경 없음.
## 결론 요약
codex R1 이 닫혔다. monotonic 은 `_sleep` 의 대기 길이에만 쓰이고, 마감·evidence 창·이벤트 시각은 벽시계 그대로라 옛 bash 정책과 같다. 새 테스트는 결정적이며 이전 코드에서 실패한다. 차단 결함 없음.
## 1. R1 종결
| 지적 (codex 1회차) | 수정 | 확인 |
|---|---|---|
| R1 `_sleep` 이 `time.time()` 으로 남은 시간을 재서, 대기 중 벽시계가 뒤로 바뀌면 대기가 늘어남(1 s→11 s, 3 s→13 s) | wrapper.py:145-156 — `end`/`left` 를 `time.monotonic()` 으로 계산 | 새 테스트 test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back[1, 3] 통과. 이전 wrapper.py(518beda)를 넣은 스크래치 사본에서는 `assert 11.0 <= 1.5`, `assert 13.0 <= 3.5` 로 **2 failed** — 지적한 증상 그대로 재현 |
## 2. 벽시계 정책이 섞이지 않는가
- monotonic 이 쓰이는 곳은 `_sleep`(:150, :153) 뿐이다. 벽시계는 그대로: `_now()`(:45-46, bash `date +%s`), 마감 `start_time + timeout`(:203), broker 재관측의 남은 시간 `deadline - _now()`(:208)와 마감 도달 판정(:218), 유예 루프 조건·evidence 창·마감 판정(:226-240), 디스크 heartbeat 의 나이 `time.time() - seen`(:115, 이벤트 timestamp 가 벽시계이므로 맞다).
- 옛 bash 와 같다: `sleep 1`/`sleep "$left"` 는 경과 시간(벽시계 무관)이고, 마감·창은 `$(date +%s)`. broker 루프는 `min(deadline - _now(), 3)` 을 벽시계로 구한 뒤 그 길이만큼 monotonic 으로 잔다 — bash 의 `left` 계산 후 `sleep "$left"` 와 동일.
- 래퍼 생존 확인(0.5 s 간격)은 `_sleep` 안의 `time.sleep(min(left, 0.5))` 와 매 반복 `_check_owner` 로, 간격 자체가 monotonic 남은 시간 기준이 되었을 뿐 확인 빈도·조건은 같다.
## 3. 테스트 결정성
- `_SteppedClock` 이 모듈의 `time` 을 대체해 실제 시간·sleep 을 쓰지 않는다(0.1 s 에 끝). 첫 sleep 중 벽시계만 10 s 역행시키고 `slept` 를 정확히 누적하므로 결과는 입력으로 결정된다(현재 코드에서 slept == seconds). 3회 반복 모두 통과. 상한 `seconds + 0.5` 와 옛 결과(+10)의 차이가 커서 오탐 여지가 없다.
- 이 테스트는 벽시계가 **앞으로** 뛰는 경우는 다루지 않지만, 그 경우는 옛 bash 와 마찬가지로 마감 쪽(벽시계)이 일찍 끝나는 것이 정책이므로 대상이 아니다.
## 4. 테스트 실행
| 실행 | 결과 |
|---|---|
| 지정 2개 파일(test_issue17_item4_decisions, test_issue33_budget), pr-51 .venv Python 3.9.6 | **278 passed / 0 failed** (325 s) |
| 새 테스트, 이전 wrapper.py 사본 | 2 failed (11.0, 13.0) |
- 브리프 주의대로 모든 실행을 `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 를 비우고 임시 HOME(스크래치)으로 돌렸다(탐침 사본에는 WORKSPACE_ROOT 를 그 사본으로 명시). 실제 .mam·다른 워크스페이스·herdr 서버를 건드리지 않았다.
- PR 설명의 전체 스위트 실패 1건(test_issue45_restore 의 $HOME 복원, herdr 0.9.3)은 이 델타가 건드리지 않는 파일이며 main 에서도 같다는 설명과 일치한다(이번에 재실행하지 않음).
## 5. 비차단
1회차 N1~N3·N5 는 후속으로 남겨 둔 데 동의한다. 이번 델타에 새 관찰은 없다.
[VERDICT: PASS]
@@ -0,0 +1,65 @@
# 리뷰 리포트 — Job e93cf9d4 (PR #51, refactor/issue-17-item4)
- 리뷰어: planner-reviewer-claude-01 (claude)
- 리뷰 identity: loop-20261007T142403Z-10d77f / base 7209750 / head 518beda / tree 137b4e4 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
## 결론 요약
동작 불변 계약이 지켜졌다. 옛 bash 판정(tests/data/issue17_item4_old_decisions.sh = 7209750 의 함수)과 새 wrapper.py 를 경로별로 대조해 차이를 찾지 못했고(옛 코드의 따옴표 경로 버그 하나는 새 코드에서 고쳐짐 — 개선), 지정 9개 파일이 이 macOS·Python 3.9·bash 3.2 에서 전부 통과한다. 차단 결함 없음. 비차단 관찰 5건(§8).
## 1. 동작 불변 — 경로별 대조
| 옛 bash (old_decisions.sh) | 새 Python (wrapper.py) | 판단 |
|---|---|---|
| _map_sub_rc :72-90 | sub_status :58-66 | 같음. rc 3 은 디스크 종결 상태가 이김, 그 외 코드는 timeout |
| 구독자 조기 종료 판정 `[ $sub_exit -eq 0 ] \|\| [ … -eq 3 ]` | subscriber_up :69-70 | 같음 |
| _rc_for :92-101 + 호출부의 `MAM_DELEGATE_FAIL_CLOSED=0 → exit 0` | exit_code :77-78, fail_closed :73-74 | 같음. 빈 값은 1 로 보는 `${…:-1}` 와 `or "1"` 일치 |
| cmd_wait rc 4→2 :261 | wait_rc :81-82 | 같음 |
| start_stall_watch 의 감시 여부 :30-36 | stall_watch :89-99 | 같음. 메시지도 동일 |
| finalize_unfinished 앞부분: FAIL_CLOSED=0·completed 면 반환, stall.out 꼬리 3줄, 디스크 종결이면 FINAL=디스크(completed 면 반환) :105-123 | finalize_begin :161-187 (`keep` / `done completed` / `go\|probe <final> <evidence_until> <deadline>`) | 같음 |
| broker_unavailable: 마감까지 ≤3 s 간격 재관측, 매 회 시계로 마감 계산, 마감 도달 시 wall :128-146 | finalize_end :201-216 | 같음(`_sleep` 이 0.5 s 단위로 쪼개 소유자를 확인할 뿐, 대기 총량은 같다) |
| timeout: evidence_until 는 진단 **전**에 계산, 마감까지 디스크 재관측, 디스크 heartbeat 가 창을 다시 열고, 창이 지나면 종료, 마감 도달 시 wall :149-194 | finalize_begin 이 evidence_until 계산(:181) → bash 가 진단 프로브(래퍼 :137-142) → finalize_end :217-238 | 같음. 진단 순서도 같다 |
| 라벨: sub_rc 4 → wall, wall 이고 phase≠inject 면 wall_clock_timeout, preflight/idle_wait 는 cancelled + session_busy 또는 PREFLIGHT_REASON :199-209 | :242-251 | 같음 |
| close rc 4(토큰 불일치) → 이벤트 억제, FINAL 그대로 :216-224 | :256-262 | 같음 |
| close 뒤 디스크 completed 면 completed :226-232 | :263-264 | 같음 |
| direct 만 이벤트: cancelled 또는 (sub_rc≠1 또는 inject) 이면 error(inject 는 agent_blocked) :234-245 | :266-272 | 같음. 토큰 인자도 동일 |
| registry.py close 출력 `2>&1` 을 stdout 으로, publish_event stderr 는 버림 | _run merge=True/False :190-193, 답은 fd 3 | 같음 — 래퍼가 `3>&1 1>&4` 로 stdout 을 원래 stdout 으로 보낸다(래퍼 :145-149). subprocess 기본 close_fds 로 fd 3 은 자식에 새지 않는다 |
- 비교 테스트(test_c_*)가 status×sub_rc×phase 전 조합, 토큰 불일치(CLOSE_RC=4), FAIL_CLOSED=0, remote·human·loop·discuss, 늦은 completed, heartbeat 유예가 마감까지 가는 경우, 래퍼 사망(KILL·미회수 좀비)을 옛·새 양쪽으로 돌린다.
- 직접 돌려 본 미포함 경로: registry 경로에 작은따옴표(`reg it's`)가 있을 때 옛 `_disk_status` 는 경로를 Python 소스에 끼워 넣어 `""` 를 내고(rc 3 + 디스크 completed 를 broker_unavailable 로 오판), 새 sub_status 는 `completed`. 옛 버그가 고쳐진 차이이며 계약 위반이 아니다.
## 2. bash ↔ Python 경계
- `_decide`(래퍼 :97-99)는 `"$@"` 로 인자를 그대로 넘긴다. 빈 값(`--dispatch-token ""`, `--phase ""`, `--preflight-reason ""`)은 argparse 기본값과 같다. `-` 로 시작하는 값이 들어갈 수 있는 인자는 PREFLIGHT_REASON 뿐인데 값은 `target_not_found` 하나(래퍼 :850). `${START_TIME:+--start-time "$START_TIME"}` 는 빈 START_TIME 이면 인자를 빼고 Python 이 now 를 쓴다 — 옛 `${START_TIME:-$(date +%s)}` 와 같다.
- 한 줄 출력: finalize-begin 은 stdout 에 답 한 줄만, 진단은 stderr. finalize-end 는 fd 3 으로 답(fd 3 이 닫혀 있으면 stdout).
- 래퍼에 판정이 남지 않았다: test_s_the_wrapper_carries_no_moved_decision(주석 제외 소스에 _map_sub_rc·_rc_for·FINALIZE_CLOCK·`${MAM_DELEGATE_FAIL_CLOSED`·SUB_RC 비교·라벨 문자열 없음, lib_int.sh 에 BASH_REMATCH 없음).
- knob 환경변수: 옛 bash 는 셸 변수로, 새 Python 은 환경으로 읽는다. 래퍼가 이 두 knob 을 대입하지 않고 .mam.env 는 `export` 줄로 들어오므로(래퍼 :37-45, envfile.py :116) 차이가 없다.
- Python 실패 시: 관찰 N2.
## 3. finalize-end 의 --watch-pid
- `_check_owner` 가 두 대기의 매 반복(0.5 s 단위)과 close 직전(:256), 이벤트 직전(:267)에 래퍼 생존을 본다. 사라졌으면 OwnerGone → 닫기·발행 없이 종료(:338-339).
- `_alive`(:122-137): kill(0) 실패면 죽음, 성공이면 `ps -o stat= -p` 가 `Z…` 이거나 빈 출력이면 죽음. macOS 와 Linux 모두 좀비는 `Z`(macOS `Z`, Linux `Z`/`Z+`)로 시작하고, 사라진 pid 는 빈 출력이다. ps 를 못 돌리면 kill(0) 결과를 믿는다(살아 있음) — 닫는 쪽이 아니라 기다리는 쪽이지만, 래퍼가 사라진 뒤라면 대기는 마감에서 끝나고 닫기가 일어날 수 있다(ps 가 없을 때만, 비현실적).
- 래퍼에 TERM trap 이 없으므로(trap 은 부트스트랩 EXIT 용 :916-942 뿐) TERM 이 `$(…)` 대기 중 지연되는 문제는 없다. 래퍼가 TERM/KILL 로 사라지면 고아가 된 Python 이 0.5 s 안에 알아채고 끝난다 — 옛 bash 루프가 래퍼와 함께 끝나던 것과 같은 결과. 테스트 test_c_finalize_ends_with_its_wrapper / _with_an_unreaped_wrapper / test_c_an_unreaped_wrapper_leaves_the_real_record_alone 통과.
- 정상 경로 지연: 확인마다 ps 한 번(수 ms). 대기의 총길이는 옛과 같다.
## 4. 정수 규칙 (lib_py/ints.py)
- positive_int 는 runner.py 의 옛 _positive_int 를 그대로 옮긴 것이고(diff 상 동일 본문), 옛 bash mam_int 와도 같은 규칙이다: ASCII 공백 6종만 앞뒤 허용, ASCII 숫자만(`٥` 거부, 로케일 무관), 선행 0 제거 뒤 9자리 이하, 최솟값. knob 의 경고 문구도 동일. test_c_mam_int_and_knob 이 C·en_US.UTF-8 두 로케일에서 옛·새를 비교하고 통과.
- lib_int.sh 를 lib.sh 없이 source 하는 diff_collect.sh·loop_lock.sh: `_MAM_INTS_PY` 를 BASH_SOURCE 기준으로 잡으므로 동작한다(test_issue21_loop_b l15 는 그 배치를 재현하도록 lib_py/ints.py 도 복사). 비용·실패는 N3.
## 5. 수정된 정적 테스트 4개
약해지지 않았고 오히려 강해졌다: 각 테스트가 래퍼 소스의 `_decide …` 호출 문자열을 계속 고정하면서, 옮겨진 표 자체(EXIT_CODES, rc 0~5 의 sub_status, rc 3 의 디스크 우선, SUB_RC_* 와 래퍼 상수의 일치)를 실제 함수로 검사한다. test_issue26_ints 의 NOT_ARITHMETIC 에서 두 knob 을 뺀 것은 그 knob 들이 셸에서 사라졌기 때문이며 정적 가드가 재유입을 막는다.
## 6. 성능
- `_decide` 한 번 ≈ 50 ms(5회 0.25 s), 그중 약 29 ms 는 `lib_py.delegation` 패키지 import(N1). 잡 하나당 호출은 정상 경로에서 3~5회(sub-status, exit-code, stall-watch, 미완료 시 finalize-begin/-end).
- `mam_int`/`mam_int_knob` 는 호출당 ≈ 47 ms(10회 0.47 s; 옛 bash 는 ≈ 0.1 ms). 래퍼 기동 시 knob 4~5개 + 플래그, send_keys 경로의 knob 몇 개. 모두 폴링 루프 밖(마감 계산은 1회)이라 잡당 1 s 미만이며 에이전트 턴 시간에 비해 무시할 수준.
## 7. 이식성
이 macOS·/usr/bin/python3 3.9.6·bash 3.2 에서 지정 9개 파일 통과. wrapper.py·ints.py 는 3.9 문법(`from __future__ import annotations`, typing.List), /proc 없음(ps 사용), str.isascii(3.7+).
## 8. 비차단 관찰
- **N1** wrapper.py 머리말의 "Stdlib only" 와 달리 `python -m lib_py.delegation.wrapper` 는 패키지 `lib_py/delegation/__init__.py` 를 먼저 import 해 service → mqtt_common → **paho** 를 싣는다(paho 없는 python3 에서 `ModuleNotFoundError: paho` 확인). 래퍼의 `$PY` 는 원래 paho 가 필요하므로 동작에는 문제없지만, 문서 정정이나 패키지 밖 모듈(ints.py 처럼 경로 실행)로 두면 호출당 ~29 ms 도 줄어든다.
- **N2** `_decide` 가 실패하면(모듈 import 오류 등) `exit "$(_decide exit-code …)"` 는 `exit ""` → bash 3.2 에서 rc 255("numeric argument required"), finalize 도 실패해 잡이 열린 채 남는다. 옛 코드도 같은 `$PY` 로 registry.py 를 돌렸으므로 실질 회귀는 아니지만, `exit "${rc:-1}"` 같은 기본값이 있으면 fail-closed 코드(1)가 보장된다.
- **N3** lib_int.sh 는 `$PY` 가 아니라 PATH 의 `python3 -I` 를 쓴다. python3 가 없으면 mam_int_knob 이 아무것도 출력하지 않아(옛 bash 는 항상 기본값 출력) `$(( $(date +%s) + $(mam_int_knob …) ))`(lib.sh:2783 등)가 산술 오류가 된다. MAM 은 python3 를 이미 필수로 요구하므로 현실적 위험은 낮다.
- **N4** 옛 `_disk_status` 의 작은따옴표 경로 버그(§1)가 새 코드에서 고쳐졌다 — 동작 차이지만 개선.
- **N5** 브리프·PR 설명의 "0.2 s 마다 확인"과 달리 코드는 0.5 s(`_sleep`, :145-153). 설명 정정만 필요.
## 9. 테스트
검증 경로에서 지정 9개 파일(pr-51 .venv, Python 3.9.6): **604 passed / 1 skipped / 0 failed** (1009 s, rc 0). skip = test_issue30_stall_watch.py:340 "real process trees via /proc"(Linux 전용, 기존). MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 없음(1052 → 1052).
[VERDICT: PASS]
@@ -0,0 +1,56 @@
# PR #51 round 2 delta review — Codex
Job: 24b21b9c
Loop: loop-20261007T162612Z-9fe4f9
Base: 518bedac5d8ab832347bfc6682b146f3201021f1
Head: 4c86107846d1e037f9a69a1201257725e8673179
Tree: e6f67f6136b8f0cec15ce98400ae7d7710cedf1e
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T162612Z-9fe4f9/1
## Conclusion
R1 is closed on code inspection and deterministic execution. No new code defect was found in the two-file delta. Overall approval is withheld solely because the required two-file suite could not execute its test bodies under this session's network restrictions. This is a validation/environment blocker, not a request to change the implementation. Run the required suite in a permitted isolated environment before promoting this review to PASS; no design escalation is needed.
## R1 closure table
| Item | Evidence | Result |
| --- | --- | --- |
| 1-second wait after a 10-second backward wall-clock step | wrapper.py:150,153; independent base/head comparison: 11.0 seconds -> 1.0 second | Closed |
| 3-second wait after the same step | wrapper.py:150,153; independent base/head comparison: 13.0 seconds -> 3.0 seconds | Closed |
| Broker re-observation consumes the corrected wait | wrapper.py:211; finalize_end with completion appearing during the wait: base 13.0 seconds, head 3.0 seconds, both return completed | Closed |
| Heartbeat grace consumes the corrected wait | wrapper.py:239; finalize_end with completion appearing during the wait: base 11.0 seconds, head 1.0 second, both return completed | Closed |
| Owner polling retained | wrapper.py:152,156; injected owner loss observes checks at elapsed 0.0, 0.5, 1.0 seconds and raises OwnerGone at 1.0 | Preserved |
| Deadline retains wall-clock policy | wrapper.py:203,226,240; forward wall-clock jump during the 1-second grace produces wall_clock_timeout at elapsed 1.0 second | Preserved |
File references above are relative to `.agents/skills/lib_py/delegation/` in the validation tree.
## Scope and test quality
Only `.agents/skills/lib_py/delegation/wrapper.py` and `tests/test_issue17_item4_decisions.py` changed (43 insertions, 3 deletions). Production changes are restricted to the `_sleep` docstring and its two clock reads. `_now`, heartbeat timestamp age, evidence windows, START_TIME + TIMEOUT, subprocess/registry/event handling, and surrounding branch logic are unchanged.
The regression tests at tests/test_issue17_item4_decisions.py:598-630 use separate simulated wall and monotonic clocks, step wall time backward once, and advance in deterministic half-second increments. They perform no real sleep or clock adjustment. Both 1- and 3-second cases distinguish the faulty base from the fixed head decisively. The extra owner/deadline probes cover behavior not asserted by these two new tests.
Previous nonblocking follow-ups and the unrelated main-branch restore failure were not reopened.
## Validation results
- Required suite attempted with Python 3.9 from `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python`, PYTHONDONTWRITEBYTECODE=1, pytest cache disabled, and external basetemp `/tmp/24b21b9c-selected`.
- Targets: `tests/test_issue17_item4_decisions.py tests/test_issue33_budget.py`.
- Result: **278 setup errors in 1.80s**, exit 1; no passing test-body claim is made for this run. Every case is blocked by the session-autouse local MQTT fixture: tests/conftest.py:1575 calls start_broker, then tests/_local_mqtt.py:90 fails at `self._lsock.bind(("127.0.0.1", 0))` with `PermissionError: [Errno 1] Operation not permitted`.
- Required-suite log: `/tmp/24b21b9c-selected.log`.
- Separate network-free run: `python -m pytest --noconftest -p no:cacheprovider -q --basetemp=/tmp/24b21b9c-unit-tests tests/test_issue17_item4_decisions.py::test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back` -> **2 passed in 0.08s**. This deliberately excludes the broker autouse fixture and is supplementary evidence, not a substitute claim for the required suite.
- Independent probe script `/tmp/24b21b9c-probes.py`: all assertions passed for base/head sleep, both finalize paths, forward wall-clock expiry, and owner polling. Actual system time was never changed; side effects and disk reads were stubbed for finalize probes.
- Both changed Python files passed AST parsing; `git diff --check BASE HEAD` passed.
- HEAD/tree matched the brief before and after validation; final `git status --porcelain` was empty.
## Reproduction and resolution of the validation blocker
Use the brief's required two-file pytest command in this restricted session. Expected: the isolated local broker starts and the 278 test bodies execute. Actual: binding the local TCP listener is denied before any test body runs. Resolution: rerun the same suite in an environment that permits its isolated loopback broker; do not modify product code, suppress the integration fixture, or classify setup errors as passing tests.
## Isolation and lifecycle
Before tests and probes, inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane binding variables were removed; temporary HOME, HOME_DIR, WORKSPACE_ROOT, XDG_CONFIG_HOME and absent MAM_ENV_FILE were supplied. The required-suite harness additionally set WORKSPACE_ROOT to each test's tmp_path via an environment-only autouse plugin, so sourcing helpers cannot generate a shim in the review snapshot or another workspace. No .mam.env was exported, no live herdr server was used, and no source files, branches or commits were changed. Only the requested report and lifecycle publisher used this job's real control-plane paths.
The requested started command was attempted first, but MQTT publication exhausted three attempts and exited 2 with Operation not permitted. The requested completed command was also executed after saving this report; all three MQTT attempts failed with Operation not permitted and the command exited 2. The report is saved, but neither lifecycle event was successfully delivered to the broker.
[VERDICT: NOT PASS]
@@ -0,0 +1,124 @@
# PR #51 — Codex review, job aa885e48
## 판정: NOT PASS
일반 입력과 안정적인 시스템 시각에서는 기존/새 판정의 비교 검사들이 일치했다. 그러나 새 _sleep이 경과 시간 측정에 wall clock을 사용해, 시스템 시각이 뒤로 이동할 때 두 finalize 재관측 간격이 달라진다. 핵심 계약인 동작 불변을 충족하려면 R1을 수정해야 한다. 수정 범위는 작은 helper와 회귀 검사이며 재계획은 필요하지 않다.
## Identity / 범위
- loop: `loop-20261007T142403Z-10d77f`
- base: `7209750ac7e3ab9a7f6b0abc3670dbe0b1060f88`
- head: `518bedac5d8ab832347bfc6682b146f3201021f1`
- tree: `137b4e46c58b4f27e03dc19e6f9c6b42f574baa3`
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T142403Z-10d77f/1`
- base..head 7개 커밋의 변경 전체를 검토했다. 제품 소스 수정·커밋·브랜치 변경은 하지 않았다.
- 검증 중 별도 탐침의 workspace 격리 실수가 있었다. 아래 “검증 과정의 격리 오류”에 별도로 기록한다.
## R1 — P2: wall-clock 역행이 짧은 sleep까지 늘려 완료/heartbeat 재관측을 지연시킨다
**근거:** `.agents/skills/lib_py/delegation/wrapper.py:145–153`은 `end = time.time() + seconds`와 `end - time.time()`으로 sleep의 남은 시간을 계산한다. 이를 broker 재관측(`:208`, 최대 3초)과 디스크 heartbeat 유예(`:236`, 1초)가 모두 사용한다.
기존 bash는 해당 위치에서 `sleep "$left"` 또는 `sleep 1`을 호출한 다음 디스크를 다시 읽는다. 새 구현은 그 짧은 sleep 도중 wall clock이 뒤로 이동하면 되돌아간 시간까지 기다린 후에야 레코드를 읽는다. 잡의 wall-clock deadline이 이동된 시각에 맞춰 판단되는 기존 정책과, 개별 sleep 자체가 늘어나는 것은 별개의 동작이다.
**재현 조건/절차:**
1. `tests/data/issue17_item4_old_decisions.sh`의 실제 옛 `finalize_unfinished`를 실행한다. 이 파일의 함수 9개는 `git show 7209750`의 대응 함수와 바이트 단위로 일치함을 확인했다.
2. 실제 시스템 시각 대신 모의 시각을 사용한다. 시작 wall time=100, START_TIME=100, TIMEOUT=60, IDLE_TIMEOUT=10. 첫 sleep 중 wall clock만 10초 뒤로 이동하고, elapsed time은 정상 증가시킨다.
3. 디스크 레코드는 elapsed 1초부터 completed를 반환하게 한다.
4. 같은 입력으로 새 `finalize_end`를 실행한다. 두 경로 모두 실제 레지스트리/브로커/서버에 접근하지 않는다.
| 경로 | 옛 bash가 completed를 확인한 elapsed | 새 Python이 확인한 elapsed |
| --- | --- | --- |
| broker_unavailable, 3초 재관측 | 3초 | 13초 |
| timeout, 1초 디스크 유예 재관측 | 1초 | 11초 |
**기대:** 기존과 같이 짧은 sleep의 경과 시간 뒤 디스크를 다시 읽고, 그 시점의 wall clock으로 잡 deadline/evidence를 판단한다.
**실제:** 이미 completed인 잡을 새 코드만 추가 10초 기다린다. 시각 보정 폭이 커지면 지연도 그만큼 커진다. 기존 비교 테스트는 안정적인 시각만 사용하므로 이 차이를 잡지 않는다.
**수정 방향:** `_sleep`의 내부 end/remaining만 `time.monotonic()`으로 계산한다. `_now()`, START_TIME+TIMEOUT, 이벤트 timestamp, evidence_until의 wall-clock 정책은 그대로 유지한다. 모의 wall clock 역행 중에도 1초/3초 간격으로 디스크를 재관측하는 비교 회귀 검사를 추가한다.
재현 파일: `/tmp/aa885e48-clock.py`
결과: `/tmp/aa885e48-clock.log`
검증 cwd에서 재실행:
```sh
PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python /tmp/aa885e48-clock.py
```
호스트 시계는 변경하지 않았다. 표의 시간은 모의 elapsed time이다.
## 브리프 검토 항목
### 1. 옛/새 판정과 finalize
- fixture의 mam_int, mam_int_knob, start_stall_watch, _disk_status, _recent_disk_heartbeat, _map_sub_rc, _rc_for, finalize_unfinished, cmd_wait 총 9개 함수를 실제 base와 비교해 일치를 확인했다.
- 일반 finalize 라벨·phase별 cancelled 이유·inject의 agent_blocked·direct만 이벤트 발행·loop/discuss의 이벤트 억제·dispatch token mismatch rc=4 시 후속 이벤트 억제는 비교 검사에서 보존됐다.
- FAIL_CLOSED=0, completed 레코드 조기 종료, terminal error/cancelled, remote/human 분기도 확인했다.
- 기존 스위트 외에 preflight detail의 공백/따옴표/줄바꿈/빈 값 4건과 broker 대기 중 error/cancelled/completed 도착 3건을 옛/새로 직접 비교했다. stdout, stderr, FINAL_JOB_STATUS, registry/publish 호출이 모두 일치했다.
- 단 두 대기의 시계 역행 동작은 R1처럼 달라진다.
### 2. bash ↔ Python 경계
`multi-agent-mux-delegate-job:97–101`의 _decide는 인자를 `"$@"`로 넘긴다. 일반 값의 공백과 따옴표는 유지되며, `:805–831`의 begin 단일 행과 end fd 3 결과 분리는 비교 검사에서 close/publish stdout과 상태가 섞이지 않았다.
Python 경로를 존재하지 않는 경로로 바꾼 추가 탐침에서 exit-code, wait-rc, finalize는 각각 nonzero(이 호스트 bash에서 255/127/127)로 끝나 성공으로 바뀌지 않았다. begin의 command substitution 실패는 read 자체에 가려질 수 있지만, 뒤의 end 실행 실패로 정상 성공 상태를 반환하지 않았다. start_stall_watch의 판정 실패는 기존의 notify-only 감시 기동 실패와 마찬가지로 감시 없이 반환하므로 모든 보조 경로가 job을 중단하는 정책은 아니다.
판정은 wrapper.py로 이동했고 bash에는 실행/대기/프로세스 정리, diagnostic probe, CLI 흐름이 남았다. 새 int 모듈의 직접 실행은 -I로 격리되며 lib.sh 없이 lib_int.sh만 source한 실행도 확인했다.
### 3. watch-pid와 종료
`wrapper.py:122–153,256,267` 부근의 owner 검사는 kill(0) 뒤 ps stat의 Z 접두사를 검사하고, 대기 중과 부작용 직전에 owner가 사라지면 종료한다. 제공된 TERM/미회수 zombie 검사와 추가 KILL 비교를 확인했다.
추가 KILL 탐침은 broker 대기와 timeout 유예 각각에서 옛/새 wrapper만 종료했다. 양쪽 모두 deadline 이후에도 close/publish 호출이 없고 레코드가 그대로였다. 새 코드의 캡처 파이프 종료는 약 0.04초/0.18초, 옛 코드는 약 2.20초/0.39초였다. 옛 코드의 남은 sleep 때문에 최초 자체 탐침의 2초 기준은 너무 짧았으며, 기존 비교 검사의 4.5초 기준으로 바로잡아 재확인했다. 이는 제품 실패로 집계하지 않았다.
실제 polling 구현은 브리프의 0.2초가 아니라 **최대 0.5초 간격**이다. 이 간격 자체에서는 차단 결함을 찾지 않았다. macOS에서 실행 검증했고 Linux는 직접 실행하지 않았으므로 Linux 실측 통과를 주장하지 않는다.
### 4. 정수 규칙
`ints.positive_int`는 이전 runner의 구현과 같은 ASCII 공백/숫자, 선행 0 제거, 유효 9자리 제한, minimum 검사를 유지한다. 옛 bash와 새 adapter의 C/en_US.UTF-8, 유니코드 공백/숫자, 0 허용 여부 및 경계 입력 비교를 확인했다. lib_int.sh의 두 함수는 Python 호출로 얇아졌고, runner도 같은 모듈을 사용한다. minimum/default가 계약 범위의 값일 때 추가 차이를 찾지 못했다.
### 5. 정적 테스트 변경
- issue5: 옛 bash 문자열 대신 호출 연결, Python EXIT_CODES, 상태 매핑, subscriber 상수 일치를 검사한다.
- tier1 G10: broker unavailable 및 디스크 completed 우선 동작을 실행 검사해 기존 문자열 검사보다 의미를 직접 고정한다.
- issue26: Python으로 옮긴 두 환경변수를 shell-only 분류표에서 제거했으며 새 단위/비교 테이블이 해당 정책을 확인한다.
- issue21 L15: 독립 복사 fixture에 ints.py를 같이 복사한다. 기존 diff-only 거부/로컬 허용 assertion은 유지됐고 이 테스트를 별도로 실행했다.
- #42 인벤토리는 근거 줄 번호만 바뀌었다. 정책 분류 변경은 없다.
### 6. 성능
이 호스트에서 정수 판정 100회의 경과 시간은 옛 bash 약 0.0056초, 새 Python adapter 약 4.392초였다. `_decide exit-code completed` 20회의 프로세스 실행 중앙값은 0.0474초(0.0459–0.0493초)였다. 기존 shell-only 판단보다 시작 비용은 분명히 늘었다.
기본 direct 완료 경로는 초기 knob 4회 + 기본 정수 flag 검증 4회 + stall-watch/sub-status/finalize-begin/exit-code 4회로 약 12회의 새 Python 실행이 발생한다. explicit idle-timeout, 조기 subscriber 종료 판정, 미완료 finalize-end, delivery/preflight knob 경로에서 더 늘어난다. 기본 구간만 대략 0.5–0.6초 추가이며 1초 안팎의 짧은 잡에서는 비율이 크다. 반면 긴 finalize 대기에서는 기존의 반복 Python 디스크 probe/date 프로세스를 인프로세스 읽기로 바꿨으므로 비용이 단순히 추가되기만 하는 것은 아니다. 지정 예산/통합 검사 결과를 함께 보았고, 이 측정 자체를 차단 사유로 삼지는 않았다.
### 7. 이식성/검증
실제 macOS, Python 3.9, bash 3.2에서 검증했다. 변경된 Python core는 해당 인터프리터 AST parse 통과, 두 변경 셸 파일 bash -n 통과, git diff --check 통과다.
## 테스트 결과
| 검증 | 결과 |
| --- | --- |
| 브리프의 9개 파일, 지정 Python 3.9 환경 | **604 passed, 1 skipped**, 1005.83초 |
| 수정된 issue21 L15 검사 추가 실행 | **1 passed**, 1.08초 |
| 옛 함수 원본 일치 확인 | 9개 함수 바이트 일치 |
| 추가 정상 입력/늦은 종결 비교 | 7건 옛/새 일치 |
| KILL 시 broker/timeout 두 경로 | 옛/새 모두 close·발행 없음 |
| Python 실행 불가 경계 | 상태/종료 판정 3경로 모두 nonzero |
| 시계 역행 비교 | **두 경로의 재관측 지연 차이 재현** — R1 |
| Python AST / bash -n / diff --check | 통과 |
검증 종료 후 HEAD/tree가 브리프와 일치했고 추적 파일의 `git status --porcelain` 출력은 없었다. 전체 저장소 스위트는 요청대로 다시 실행하지 않았다.
추가 자료:
- 전체 지정 선택 검사: `/tmp/aa885e48-selected.log`
- 변경된 L15 추가 검사: `/tmp/aa885e48-l15.log`
- KILL/실행 실패/정수 성능 탐침: `/tmp/aa885e48-boundary.py`, `/tmp/aa885e48-boundary-v2.log`
- 시계 역행 비교: `/tmp/aa885e48-clock.py`, `/tmp/aa885e48-clock.log`
## 검증 과정의 격리 오류
추가 7개 옛/새 비교를 pytest 밖에서 호출한 첫 탐침에서, 상속된 WORKSPACE_ROOT를 제거하지 않은 제 실수가 있었다. 그 값은 검증 저장소가 아닌 `/Users/godopu16/PuKi/lac/curriculum/00_entry`였고, wrapper source 시 lib.sh의 자동 초기화가 `/Users/godopu16/PuKi/lac/curriculum/00_entry/.mam/shim/herdr` 생성 파일을 다시 썼다. 이 경로 변경은 사용자에게 즉시 알렸다.
해당 탐침의 registry/publish는 stand-in으로 가로챘고 herdr 진단 함수도 stub이었다. 실제 에이전트/서버 작업은 하지 않았다. 다만 그 생성 파일의 실행 전 사본을 확보하지 않았으므로 이전 내용과 동일했는지, 원복됐는지는 주장하지 않는다. 공유 파일을 추측으로 덮어써 복구하지 않았다. 이후 탐침은 HERDR/MAM/MQTT 환경을 제거하고 임시 WORKSPACE_ROOT/HOME을 명시했다. 이 오류는 PR 제품 결함 R1과 별개의 검증 절차 문제다.
[VERDICT: NOT PASS]
@@ -0,0 +1,189 @@
# PR #51 (refactor/issue-17-item4, Refs #17 Item 4) Comprehensive Review Report
- **Reviewer Agent**: `agy` (session: `herdr:reviewer-agy-01`)
- **Job ID**: `07c66956`
- **Verification Directory**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T142403Z-10d77f/1`
- **Base Commit**: `7209750ac7e3ab9a7f6b0abc3670dbe0b1060f88` (main, v4.8.5)
- **Head Commit**: `518bedac5d8ab832347bfc6682b146f3201021f1` (refactor/issue-17-item4)
- **Cumulative Commits**: 7 commits (`63d9c20` ~ `518beda`)
---
## 1. Executive Summary
PR #51 implements Item 4 of Issue #17: migrating the `multi-agent-mux-delegate-job` wrapper's core decision logic from bash shell scripts into pure Python modules (`lib_py/delegation/wrapper.py` and `lib_py/ints.py`).
The central contract of this refactoring is **strict behavioral invariance**:
- Exit codes, final status labels, registry state mutations, MQTT event emissions, stall watcher triggers, and heartbeat grace periods must behave identically to the baseline commit `7209750`.
- The bash wrapper is reduced to low-level execution primitives (process spawning, wait/kill, signal handling, and keystroke injection), branching solely on the output or exit status returned by `_decide`.
A comprehensive audit was performed across all 7 evaluation criteria specified in the brief. All test suites in the verification worktree executed successfully (**604 passed, 1 skipped in 1004.68s**). The refactoring adheres strictly to the behavioral invariant contract, improves maintainability, and introduces zero regressions.
---
## 2. In-Depth Evaluation Against Review Criteria
### Criterion 1: Behavioral Invariance (Old Bash vs. New Python Wrapper)
Every decision path was audited by comparing the baseline bash implementation in [`tests/data/issue17_item4_old_decisions.sh`](file:///tests/data/issue17_item4_old_decisions.sh) (and `git show 7209750:...`) against [`lib_py/delegation/wrapper.py`](file:///.agents/skills/lib_py/delegation/wrapper.py):
| Component / Subcommand | Baseline Bash Implementation (`7209750`) | New Python Implementation (`518beda`) | Invariance Analysis |
|---|---|---|---|
| `sub-status` | `_map_sub_rc` (lines 770–788): rc 0 → `completed`, 1 → `error`, 3 → checks disk terminal (`completed`/`error`/`cancelled`) else `broker_unavailable`, others → `timeout`. | `wrapper.py:sub_status` (lines 182–190): exact mirror of the mapping table and disk inspection. | **IDENTICAL**: Verified across all return codes and disk states via `test_u_sub_status_table` and `test_c_sub_status_exit_code_and_subscriber_up`. |
| `subscriber-up` | `[ $sub_exit -eq $SUB_RC_COMPLETED ] \|\| [ $sub_exit -eq $SUB_RC_BROKER_UNAVAILABLE ]` (lines 352, 530). | `wrapper.py:subscriber_up` (lines 193–194): returns `rc in (0, 3)`. | **IDENTICAL**: Validated via `test_u_subscriber_up`. |
| `exit-code` | `_rc_for` (lines 790–800) + `[ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ] && exit 0`. | `wrapper.py:exit_code` (lines 201–203): returns `0` when `not fail_closed()`, else maps status via `EXIT_CODES` dict with default `1`. | **IDENTICAL**: Validated via `test_u_exit_code_table`. |
| `wait-rc` | `[ "$rc" -eq "$SUB_RC_WALL_CLOCK" ] && rc=$SUB_RC_NO_EVENT` in `cmd_wait` (line 1263). | `wrapper.py:wait_rc` (lines 206–207): maps `SUB_RC_WALL_CLOCK (4)` to `SUB_RC_NO_EVENT (2)`. | **IDENTICAL**: Validated via `test_u_wait_rc` and `test_c_cmd_wait`. |
| `stall-watch` | `start_stall_watch` (lines 712–721): checks `MAM_STALL_WATCH != 0`, `! _is_remote`, `agent != human`, and `session != human`. | `wrapper.py:stall_watch` (lines 213–223): mirrors environment check, `remote:` prefix, and `human` agent/session handling. | **IDENTICAL**: Emits identical stderr warnings; validated via `test_u_stall_watch_table` and `test_c_start_stall_watch`. |
| `finalize-begin` | Lines 807–891: early return if not fail-closed or completed; dumps tail of `.stall.out`; checks disk status; computes deadline and idle window; evaluates diagnostic probe condition. | `wrapper.py:finalize_begin` (lines 285–312): outputs `keep`, `done <status>`, or `go\|probe <final> <evidence_until> <deadline>`. | **IDENTICAL**: Perfectly preserves early exits, stall log dumps, and diagnostic probe triggering. |
| `finalize-end` | Lines 831–960: loops for `broker_unavailable` (every 3s) and `timeout` (heartbeat grace window); sets `close_req` (`wall_clock_timeout`, `deadline_timeout`, `cancelled`); handles CAS/ABA rc 4; publishes events. | `wrapper.py:finalize_end` (lines 320–398): executes identical wait loops, CAS suppression on `registry.py close` returning 4, and event emissions. | **IDENTICAL**: Re-observation timing, CAS suppression, and MQTT event payloads match exactly; verified in `test_c_finalize_labels_closes_and_events` and variants. |
---
### Criterion 2: Bash ↔ Python Interface Boundary
1. **Invocation Encapsulation**:
All invocations route through a single helper defined in [`multi-agent-mux-delegate-job:592-594`](file:///.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L592-L594):
```bash
_decide() {
PYTHONPATH="$SCRIPT_DIR/..${PYTHONPATH:+:$PYTHONPATH}" "$PY" -m lib_py.delegation.wrapper "$@"
}
```
2. **Argument Safety and Quoting**:
- All shell variables passed to `_decide` are quoted rigorously: e.g., `_decide sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"`.
- Conditional flags utilize parameter expansion safely: `${START_TIME:+--start-time "$START_TIME"}` and `${PREFLIGHT_REASON:+--preflight-reason "$PREFLIGHT_REASON"}` omit flags when unset without empty string token pollution.
3. **Stream and Output Channel Separation (File Descriptor 3 Design)**:
In `finalize_unfinished` ([lines 966–971](file:///.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job#L966-L971)):
```bash
{ out="$(_decide finalize-end ... 3>&1 1>&4 4>&-)"; } 4>&1
```
Combined with `_answer(text)` in [`wrapper.py:400-405`](file:///.agents/skills/lib_py/delegation/wrapper.py#L400-L405):
```python
def _answer(text: str) -> None:
try:
os.write(3, (text + "\n").encode())
except OSError:
print(text)
```
- Standard stdout (FD 1) from internal CLI calls (such as `registry.py close`) is redirected directly to the outer wrapper's stdout (FD 4), preserving user-visible audit output.
- The final status string is sent exclusively over FD 3 (redirected to FD 1 inside the subshell capture), guaranteeing that variable `out` receives only the clean, single-line return value.
4. **Fail-Closed Execution**:
If Python execution fails or crashes:
- `_decide exit-code "$job_status"` returns a non-zero exit code (`1` or `2`), causing bash to exit non-zero immediately.
- `_decide finalize-begin` failing leaves `step=""`, bypassing abnormal branch execution.
5. **No Residual Decision Logic in Bash**:
Verified by static analysis in `tests/test_issue17_item4_decisions.py:test_s_the_wrapper_carries_no_moved_decision`. The shell script no longer contains `_map_sub_rc`, `_rc_for`, `_disk_status`, `_recent_disk_heartbeat`, `FINALIZE_CLOCK`, `$SUB_RC_` comparisons, or status label strings (`wall_clock_timeout`, `agent_blocked`, etc.).
---
### Criterion 3: Process Lifetime and Zombie Detection (`--watch-pid`)
1. **Owner Tracking via `--watch-pid`**:
[`lib_py/delegation/wrapper.py`](file:///.agents/skills/lib_py/delegation/wrapper.py#L246-L266) implements `_alive(pid: int)`:
```python
def _alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
try:
state = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True,
timeout=10).stdout.strip()
except (OSError, subprocess.SubprocessError):
return True
return bool(state) and not state.startswith("Z")
```
2. **Zombie Disambiguation (Commits `c5cebe6` & `518beda`)**:
- When a parent bash process receives `SIGTERM` or `SIGKILL`, it may transition to an unreaped zombie (`stat` starting with `Z`). Under standard POSIX semantics, `kill(0)` continues to return success for zombie processes.
- `_alive()` explicitly parses `ps -o stat=` on both macOS Darwin and Linux. Any process in state `Z` is treated as dead (`_alive() == False`).
3. **Termination Semantics**:
- During sleep intervals in `_sleep()`, `_check_owner()` polls the owner every 0.5s.
- If `OwnerGone` is raised, `finalize_end` terminates immediately without executing `registry.py close` or publishing MQTT events. This prevents orphaned background processes from closing records or emitting events after the orchestrator or wrapper has died.
- Verified by `test_c_finalize_ends_with_its_wrapper` and `test_c_finalize_ends_with_an_unreaped_wrapper`.
---
### Criterion 4: Integer Parsing Contract (`lib_py/ints.py`)
1. **Canonical Integer Rules**:
[`lib_py/ints.py:positive_int`](file:///.agents/skills/lib_py/ints.py#L499-L509) enforces:
- ASCII whitespace stripping (`" \t\n\r\v\f"`).
- Strict ASCII digits check (`s.isascii() and s.isdigit()`), rejecting unicode numbers (e.g. Arabic-Indic numerals `٣٠`).
- Octal prevention: `s.lstrip("0") or "0"` prevents leading zeros from triggering octal interpretation (e.g. `0700` is parsed as decimal `700`, `0900` is parsed as decimal `900`).
- Length bounding: `len(s) <= 9`, enforcing `n <= 999999999`.
- Range validation: `n >= minimum`.
2. **Unified Implementation**:
- [`lib_int.sh:mam_int`](file:///.agents/skills/lib_int.sh#L78-L80) invokes `python3 -I "$_MAM_INTS_PY" int "${1-}" "$2"`.
- [`lib_int.sh:mam_int_knob`](file:///.agents/skills/lib_int.sh#L98-L100) invokes `python3 -I "$_MAM_INTS_PY" knob "$1" "${!1-}" "$2" "$3"`.
- [`lib_py/loop/runner.py:68`](file:///.agents/skills/lib_py/loop/runner.py#L68) sets `_positive_int = ints.positive_int`.
- Using `python3 -I` ensures complete isolation from `PYTHONPATH` or site-packages.
- Tested across locales (`C`, `en_US.UTF-8`) in `test_c_mam_int_and_knob`.
---
### Criterion 5: Static Test Updates
The 4 updated static test files were analyzed to confirm contracts were maintained:
1. [`tests/test_issue5_phase1_delegation.py`](file:///tests/test_issue5_phase1_delegation.py): Updated literal checks to assert `_decide sub-status`, `_decide exit-code`, and direct import testing of `EXIT_CODES` and `sub_status`.
2. [`tests/test_tier1_unit.py`](file:///tests/test_tier1_unit.py): Updated `test_g10_delegate_job_rc3_not_mistaken_for_error` to verify that `_decide sub-status "$sub_rc"` is called and that `wrapper.sub_status(3, ...)` evaluates correctly.
3. [`tests/test_issue26_ints.py`](file:///tests/test_issue26_ints.py): Updated `NOT_ARITHMETIC` classification because `MAM_DELEGATE_FAIL_CLOSED` and `MAM_STALL_WATCH` are now read directly in Python rather than shell arithmetic.
4. [`tests/test_issue21_loop_b.py`](file:///tests/test_issue21_loop_b.py): Added `lib_py/ints.py` to the stand-in root directory so `lib_int.sh` functions correctly in isolation.
No test assertions or safety invariants were relaxed.
---
### Criterion 6: Performance & Process Overhead
- In standard execution, `_decide` is invoked:
- Startup: `stall-watch` (1 invocation)
- Completion: `sub-status` (1 invocation) and `exit-code` (1 invocation)
- Unfinished/Failure path: `finalize-begin` (1 invocation) and `finalize-end` (1 invocation)
- **Total Overhead**: 2 to 4 lightweight Python invocations per delegated job.
- On macOS/Linux, `python3 -I` and `python3 -m` require ~20–30ms per invocation. Across a delegated job lifecycle taking multiple seconds to minutes, an cumulative overhead of ~80–120ms is completely negligible.
---
### Criterion 7: Portability & Host Compatibility
- Host environment: macOS Darwin, Apple Silicon, Python 3.9.6, GNU bash 3.2.57.
- Dependencies: Exclusively Python standard library modules (`argparse`, `calendar`, `json`, `os`, `subprocess`, `sys`, `time`).
- Syntax: No Python 3.10+ language constructs (no match/case, no `|` type unions); no Linux `/proc` dependency; POSIX-compliant `ps -o stat=` usage.
---
## 3. Test Suite Verification Results
All tests were executed in the isolated review worktree:
`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T142403Z-10d77f/1`
### Execution Command:
```bash
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python \
-m pytest -p no:cacheprovider -q --basetemp=/tmp/pytest-pr51 \
tests/test_issue17_item4_decisions.py \
tests/test_issue26_ints.py \
tests/test_tier1_unit.py \
tests/test_issue5_phase1_delegation.py \
tests/test_issue33_budget.py \
tests/test_issue30_stall_watch.py \
tests/test_issue30_stall_watch_stop.py \
tests/test_issue18_liveness_contract.py \
tests/test_issue14_delivery_ack.py
```
### Result:
- **Status**: PASSED
- **Exit Code**: `0`
- **Summary**: `604 passed, 1 skipped in 1004.68s (0:16:44)`
---
## 4. Conclusion & Final Verdict
PR #51 successfully transitions complex bash delegation logic to Python with absolute behavioral invariance. Process lifecycle tracking, zombie handling, integer parsing, and fail-closed guarantees are preserved and validated.
[VERDICT: PASS]
@@ -0,0 +1,107 @@
# PR #51 (refactor/issue-17-item4) Round 2 Delta Review Report
- **Reviewer Agent**: `agy` (session: `herdr:reviewer-agy-01`)
- **Job ID**: `f430b100`
- **Verification Directory**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T155310Z-56d5a7/1`
- **Base Commit**: `518bedac5d8ab832347bfc6682b146f3201021f1` (Round 1 head)
- **Head Commit**: `4c86107846d1e037f9a69a1201257725e8673179` (Round 2 head)
- **Delta Under Inspection**: `git diff 518beda 4c86107` (Commit `4c86107`)
---
## 1. Executive Summary
In Round 1, `claude`, `agy`, and `grok` approved PR #51 (`PASS`), while `codex` reported finding `R1`: `wrapper._sleep` previously computed elapsed intervals using `time.time()`. If the host wall clock stepped backward during broker re-observation or heartbeat grace loops (due to NTP sync or clock adjustments), the sleep duration was artificially extended by the step-back delta (e.g. 1s became 11s), deviating from bash's `sleep` which measures elapsed time via monotonic OS timers.
Commit `4c86107` fixes this issue by switching `_sleep` to `time.monotonic()`. Absolute deadlines, evidence windows, and timestamps continue to use wall clock (`time.time()`), strictly matching the original bash behavior.
All review criteria have been evaluated and verified. The unit tests and full relevant suites passed cleanly (**321 passed, 0 failed**).
---
## 2. Evaluation of Codex R1 Finding
| # | Item | Defect Description | Resolution in `4c86107` | Verification Status |
|---|---|---|---|---|
| **R1** | Sleep duration stretched by wall clock step-back | `wrapper._sleep` used `end = time.time() + seconds` and `left = end - time.time()`. A backward wall clock shift increased `left`, unduly delaying broker re-observation (up to 3s) and disk heartbeat grace checks (1s). | In [`lib_py/delegation/wrapper.py:_sleep`](file:///.agents/skills/lib_py/delegation/wrapper.py#L145-L160), interval tracking switched to `time.monotonic()` (`end = time.monotonic() + seconds`, `left = end - time.monotonic()`). | **CLOSED** (Verified) |
---
## 3. Analysis Against Review Criteria
### Criterion 1: Resolution of Codex R1
In the baseline bash implementation, `sleep 1` or `sleep "$left"` invokes the kernel sleep syscall, which measures relative elapsed duration monotonically and is completely unaffected by wall clock time adjustments.
With commit `4c86107`, `_sleep` now computes relative duration strictly using `time.monotonic()`, eliminating any sensitivity to system clock backward jumps and restoring complete behavioral identity with bash.
### Criterion 2: Monotonic vs. Wall-Clock Separation & Policy Preservation
The usage of `time.monotonic()` is strictly confined to internal sleep duration slicing in `_sleep`:
- **Sleep Span Measurement**: `end = time.monotonic() + seconds` and `left = end - time.monotonic()` measure relative elapsed time.
- **Process Liveness Polling**: `time.sleep(min(left, 0.5))` ensures the parent wrapper's PID is inspected via `_check_owner()` at least every 0.5s.
- **Absolute Deadlines**: In `finalize_begin` and `finalize_end`, deadline calculation and comparison remain anchored to the wall clock:
- `deadline = (start_time if start_time is not None else _now()) + timeout`
- `left = deadline - _now()`
- `if _now() >= deadline: clock = "wall"`
- **Evidence Windows**: `evidence_until = _now() + idle_timeout` and `if _now() >= evidence_until: break` use wall clock.
- **Timestamping**: Event logging and `events.log` timestamps rely on UTC calendar time (`time.time()` / `time.gmtime()`).
There is zero conflation between the clocks: relative interval sleeping uses monotonic time, while protocol deadlines and audit timestamps use wall clock.
### Criterion 3: Test Determinism and Coverage
The new test [`test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back`](file:///tests/test_issue17_item4_decisions.py#L94-L105) utilizes a mock `_SteppedClock`:
- Simulates a 10s backward jump of the wall clock during the first sleep cycle.
- Advances both `wall` and `mono` deterministically upon `sleep(dt)`.
- Validates that for `seconds = 1` and `seconds = 3`, total elapsed slept time is bounded within `[seconds, seconds + 0.5]`.
- Because the mock clock operates virtually in memory without real-time OS sleep delays, execution completes in microseconds with 100% determinism.
### Criterion 4: Scope and Surgical Precision
The delta across commits `518beda`..`4c86107` modifies only 2 files:
- `lib_py/delegation/wrapper.py`: +5 lines, -2 lines (updated docstring and clock source).
- `tests/test_issue17_item4_decisions.py`: +37 lines (added mock clock class and unit test).
Zero unrelated files or formatting changes were touched.
---
## 4. Test Suite Execution & Verification Results
All tests were executed within the designated review directory:
`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T155310Z-56d5a7/1`
### 4.1. Decisions & Budget Suites (Primary Delta)
```bash
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python \
-m pytest -p no:cacheprovider -q --basetemp=/tmp/pytest-pr51-r2 \
tests/test_issue17_item4_decisions.py \
tests/test_issue33_budget.py
```
- **Exit Code**: `0`
- **Output**: `278 passed in 318.06s (0:05:18)`
### 4.2. Auxiliary Liveness & Delivery Ack Suites
```bash
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python \
PYTHONDONTWRITEBYTECODE=1 \
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python \
-m pytest -p no:cacheprovider -q --basetemp=/tmp/pytest-pr51-r2-ack \
tests/test_issue18_liveness_contract.py \
tests/test_issue14_delivery_ack.py
```
- **Exit Code**: `0`
- **Output**: `43 passed in 229.14s (0:03:49)`
**Combined Results**: `321 passed, 0 failed`.
---
## 5. Non-Blocking Informational Notes
The non-blocking suggestions surfaced in Round 1 (claude N1–N5: package import vs paho docstring note, `_decide` 255 fallback, `lib_int.sh` python3 path, PR description typo 0.2s vs 0.5s) are recognized as follow-up cleanup items that do not compromise correctness or security.
---
## 6. Conclusion & Final Verdict
Codex R1 is cleanly resolved with surgical precision. Interval sleeping is monotonic, deadline policy is preserved, and all tests pass deterministically.
[VERDICT: PASS]
@@ -0,0 +1,42 @@
# Review: PR #51 round 2 @ 4c86107
- Role: Reviewer (grok)
- Identity: loop `loop-20261007T155310Z-56d5a7`, base `518bedac5d8ab832347bfc6682b146f3201021f1`, head `4c86107846d1e037f9a69a1201257725e8673179`, tree `e6f67f6136b8f0cec15ce98400ae7d7710cedf1e`, one commit, clean snapshot
- Scope: `git diff 518beda HEAD`. Two files, +43/−3, `git diff --check` clean. Main stayed `7209750`.
- Verified read-only at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T155310Z-56d5a7/1`. Python 3.9.6 from the pr-51 venv. Inherited `HERDR_*`, `MAM_*`, and `MQTT_*` were unset. `HOME` and `WORKSPACE_ROOT` were the basetemp. `.mam.env` was not exported.
## Codex R1
| Codex | 4c86107 |
|---|---|
| R1. `_sleep` measured the remaining time with `time.time()`. A wall clock stepped back during the broker re-observe (up to 3 s) or the disk-heartbeat grace (1 s) stretched that one wait by the same amount. A 10 s step back turned 1 s into 11 s and 3 s into 13 s. Bash `sleep 1` / `sleep "$left"` does not follow the wall clock. | `_sleep` (`wrapper.py:145-156`) sets `end = time.monotonic() + seconds` and computes `left` from `time.monotonic()`. The 0.5 s owner poll is `min(left, 0.5)` on that same remainder. `test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back` steps the wall clock back 10 s on the first sleep for both 1 s and 3 s. |
## Wall clock stays the deadline
The monotonic clock is used only inside `_sleep` (`:150`, `:153`). Everything that decides whether the job is finished still reads the wall clock:
- `_now` is `int(time.time())` (`:45-46`), the same value bash's `$(date +%s)` returns.
- `finalize_begin` builds `deadline` and `evidence_until` from `_now` (`:182-184`).
- The broker loop takes `left = deadline - _now()` and only then calls `_sleep(a, min(left, 3))` (`:208-211`). After the sleep it labels the clock wall when `_now() >= deadline` (`:218`).
- The grace loop's condition, the heartbeat reopen, and the idle-window check all use `_now` (`:226-240`). The sleep inside that loop is `_sleep(a, 1)` (`:239`).
- `recent_disk_heartbeat` still ages the events.log timestamp with `time.time()` (`:115`).
A wall step back during one sleep no longer lengthens that sleep. The next loop pass still sees the stepped wall clock and can sleep again until `START_TIME + TIMEOUT`, which is what the old bash loop did after each fixed `sleep`. A wall step forward is noticed on the next `_now()` check, after the current monotonic slice returns. The owner poll does not become a deadline: it only splits the slice `_sleep` was given.
`subprocess.run(..., timeout=10)` on `ps` (`:133-134`) is unchanged and is not the job deadline.
## The test
`_SteppedClock` (`tests/test_issue17_item4_decisions.py:598-616`) is the module's `time`. `sleep(dt)` adds `dt` to both clocks and to `slept`, and subtracts 10 s from the wall clock once. There is no real `time.sleep` and no thread. `monkeypatch.setattr(decide, "time", clock)` (`:628`) replaces the `import time` the wrapper calls (`wrapper.py:35`).
For 1 s the fake clock records two 0.5 s sleeps (`slept == 1`). For 3 s it records six (`slept == 3`). `0.5` is exact in binary floating point, so the result does not depend on the scheduler. The bound `seconds <= slept <= seconds + 0.5` (`:630`) rejects the old 11 s / 13 s waits. `watch_pid=None` skips the process check. The same two durations are the ones `finalize_end` passes: `min(left, 3)` and `1`.
## Scope
The commit changes `_sleep` and adds this test. No label, registry write, event, or stall-watch decision moves. The notes left for a later change (the wrapper importing a package that loads paho, `exit ""` → 255, `lib_int.sh` using `python3` on `PATH`, and the 0.2 s text versus the 0.5 s poll) are not in this diff.
## Tests
`--basetemp=/tmp/b78afollow.yeVKnK`. `tests/test_issue17_item4_decisions.py` and `tests/test_issue33_budget.py`: **278 passed** in 318.09 s, exit 0. Snapshot `git status` was empty after the run.
[VERDICT: PASS]
@@ -0,0 +1,70 @@
# Review: PR #51 @ 518beda
- Role: Reviewer (grok)
- Identity: loop `loop-20261007T142403Z-10d77f`, base `7209750ac7e3ab9a7f6b0abc3670dbe0b1060f88` (main, v4.8.5), head `518bedac5d8ab832347bfc6682b146f3201021f1`, tree `137b4e46c58b4f27e03dc19e6f9c6b42f574baa3`, ancestor of head, clean snapshot
- Scope: `git diff 7209750 HEAD`. 15 files, +1353/−292, `git diff --check` clean. Main stayed `7209750`.
- Verified read-only at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T142403Z-10d77f/1`. Python 3.9.6, bash 3.2.57.
The contract is behavior invariance with 7209750: exit codes, terminal labels, registry writes, event publish, and the stall watcher. The frozen copy in `tests/data/issue17_item4_old_decisions.sh` matches that base byte for byte: `mam_int` and `mam_int_knob` are the old `lib_int.sh` bodies, and `start_stall_watch`, `_disk_status`, `_recent_disk_heartbeat`, `_map_sub_rc`, `_rc_for`, `finalize_unfinished`, and `cmd_wait` are the old wrapper bodies.
## 1. Decisions
`finalize_begin` (`wrapper.py:161-187`) and `finalize_end` (`:196-273`) follow the old function's order.
- `MAM_DELEGATE_FAIL_CLOSED=0` or status `completed` returns `keep`. The wrapper returns before any close (`multi-agent-mux-delegate-job:812-813`).
- A disk status of `completed` returns `done completed` (`:176-177`) and the wrapper sets `FINAL_JOB_STATUS` and returns (`:814`).
- Broker re-observe sleeps `min(left, 3)` first, then reads the disk, and stops at the wall deadline (`:201-216`). A disk `completed` during that wait returns immediately. `error` or `cancelled` becomes the final status and leaves the loop.
- The timeout grace opens `evidence_until` before the herdr probe (`:181`, then the probe at `:817-821`), same order as the old function. Each pass reads the disk, re-opens the idle window from a fresh events.log line (`recent_disk_heartbeat`, `:102-115`), and otherwise stops when the idle window or the wall deadline is reached (`:223-238`). The sleep inside that loop is 1 s.
- The label is `wall_clock_timeout` when the clock is the wall and the phase is anything but `inject`; otherwise `deadline_timeout` (`:245`). Subscriber rc 4 forces the wall clock (`:243-244`). `preflight` and `idle_wait` close as `cancelled` / `session_busy`, and a non-empty preflight reason replaces `session_busy` (`:247-251`).
- Registry close rc 4 suppresses the event and returns the final status (`:258-262`). A disk `completed` after a successful close returns `completed` with no event (`:263-264`). Only `type=direct` publishes, and an error publish is skipped when the subscriber rc is 1 unless the phase is `inject`, which publishes `agent_blocked` (`:266-272`).
- `fail_closed` is off only for the exact value `0` (`:73-74`). `exit_code` then returns 0 (`:77-78`). The inject failure path still exits 1 (`delegate-job:384`, `:563`), which is the exit 7209750 used there.
- Remote is `remote:*`, the same case as 7209750's `_is_remote` (that line is `remote:*)`). `stall_watch` uses `session.startswith("remote:")` (`wrapper.py:93-95`). Human is the agent or the bare session (`:96-98`).
The comparison test runs the sourced old functions and the new wrapper on the same input (`tests/test_issue17_item4_decisions.py:232-253`). The matrix is 6 statuses × 6 phases past the wall clock (`:274-284`), plus idle clock, human, remote, the fifth-arg session, loop, discuss, disk completed/error/cancelled/missing/garbage, empty token, empty and `target_not_found` preflight reasons, `idle_wait`, close rc 4 and 1, `MAM_DELEGATE_FAIL_CLOSED=0`, a completed status with a disk error, and a 1 s broker re-observe (`:287-322`). Heartbeats that stay fresh run to the wall clock (`:325`), and a record that becomes `completed` during the grace ends as `completed` with no close (`:337`). Those tests passed.
## 2. Bash / Python boundary
`_decide` is the only helper (`delegate-job:99-101`): `python -m lib_py.delegation.wrapper` with the caller's arguments. On this bash, an empty session and agent, a session containing a space, and an agent containing a quote are one argv each. `stall-watch` accepts them and returns 0, which is "watch" for a session that is neither remote nor human.
A missing module inside an assignment aborts under the wrapper's `set -euo pipefail` (rc 1). That covers `sub-status` and `exit-code`. `read` of a failed `finalize-begin` does not itself abort; `step` is empty, and the following `finalize-end` assignment then aborts, so the close does not run. `exit "$(failing python)"` is rc 255 (`numeric argument required`). `subscriber-up` is in an `if`, so a failed interpreter is "not up", and the assignment that follows abort the script. No decision name remains in the wrapper: the static guard strips comments and rejects `_map_sub_rc`, `_rc_for`, `_disk_status`, `_recent_disk_heartbeat`, `FINALIZE_CLOCK`, the two knob expansions, subscriber-rc comparisons, the four close labels, and `BASH_REMATCH` in `lib_int.sh` (`test_issue17_item4_decisions.py:471-482`).
A positional or option value that itself starts with `-` is parsed as a flag (`stall-watch` of `-herdr:x` exits 2, and `|| return 0` at `delegate-job:692` then skips the watcher). Live sessions are `herdr:` or `remote:`. If a leading dash should stay positional, the call wants `--` before the session and agent.
## 3. Wrapper liveness
`_alive` (`wrapper.py:122-137`) is `kill(0)`, then `ps -o stat=`. `ProcessLookupError` is dead, `PermissionError` is alive, an empty state or a state starting with `Z` is dead, and a `ps` error is alive. That is the same rule as `tests/conftest.py:100-109`. macOS and Linux both mark a zombie `Z` or `Z+`, so the prefix covers both. The check runs inside every sleep (`:144-153`), immediately before close (`:256`), and immediately before publish (`:267`). `main` catches `OwnerGone`, writes nothing on fd 3, and returns 0 (`:336-340`), so the wrapper's `FINAL_JOB_STATUS` stays empty and no close line was captured.
`_sleep` checks the owner, sleeps at most 0.5 s, and returns when the requested slice is over (`:144-153`). The grace slices stay 1 s and `min(left, 3)`. The task text says 0.2 s; the code uses 0.5 s. The check sits inside the existing slice, so the close clock does not gain a step. TERM during either grace, an unreaped zombie, and a real registry record of type `loop` all leave the record unchanged and publish nothing (`test_issue17_item4_decisions.py:357-392`, `:517-593`). Those tests passed on this Mac.
## 4. Integers
`positive_int` (`ints.py:25-34`) is the body that `runner.py` used to inline. The runner now aliases it (`runner.py` imports `lib_py.ints`). ASCII blanks only, ASCII digits only, leading zeros stripped, more than 9 significant digits rejected before `int()`, then `minimum <= n`. `knob` returns the default for an empty value with no warning, and warns on an invalid value (`:37-45`).
`lib_int.sh:15-24` is only `python3 -I` of that file. Isolated CLI on this host: `0700` is 700, `0999999999` is 999999999, `1000000000` is rejected, the Arabic digit `٥` is rejected, an empty knob prints the default, and a whitespace-only knob warns and prints the default. `test_c_mam_int_and_knob` compares the old bash functions with `lib_int.sh` under `C` and `en_US.UTF-8`.
`diff_collect.sh:14` and `loop_lock.sh:7` source `lib_int.sh` when `mam_int_knob` is not already defined. The script path is next to `lib_int.sh`, independent of the caller. `diff_collect.sh` reads two knobs at source time (`:15-16`); `loop_lock.sh` reads one (`:8`). A missing `python3` returns that command's status. The rest of the wrapper already requires Python. `test_l15_truncated_diff_refuses_diff_only_but_not_local` copies `ints.py` beside the copied `lib_int.sh` (`test_issue21_loop_b.py:397-398`) and passed in 1.02 s (`--basetemp=/tmp/c7eel15.inE8zI`).
## 5. Static pins
The four edited tests still pin the contract, and they execute it.
- `test_issue5_phase1_delegation.py:385-404` requires `_decide sub-status`, `_decide exit-code`, and `--sub-rc`. `EXIT_CODES` is completed 0, error 1, timeout 2, broker_unavailable 3, cancelled 5. `sub_status` for rc 0..5 is completed, error, timeout, broker_unavailable, timeout, timeout. The wrapper's `SUB_RC_*` tuple matches `job_subscriber`'s `RC_*`, and the wrapper does not compare subscriber codes to raw digits.
- `test_tier1_unit.py` `test_g10` calls `sub_status(3)` and gets `broker_unavailable`, then `completed` once the disk record is `completed`.
- `test_issue26_ints.py` drops `MAM_DELEGATE_FAIL_CLOSED` and `MAM_STALL_WATCH` from `NOT_ARITHMETIC` because wrapper.py reads them. B5 still fails if a new `${MAM_…:-digit}` appears unclassified. The wrapper guard rejects those two expansions.
- `test_issue21_loop_b.py` only adds the `ints.py` copy the new `lib_int.sh` runs. The l15 test passed.
`tests/data/skill_code_blocks.json` keeps class `c` and kind `invocation`. The anchors moved with the deletions: `main()` is `delegate-job:1046-1057`, the runner usage line is `runner.py:155`, and `runner.main` is `runner.py:1779`. `publish_event.py` and `MESSAGING.md` rename `_recent_disk_heartbeat` to `recent_disk_heartbeat`.
## 6. Performance
A direct job that reaches the subscriber starts Python five times: `stall-watch`, `sub-status`, `finalize-begin`, `finalize-end`, and `exit-code`. Five `exit-code` calls took 0.35 s on this host, about 70 ms each. `finalize-end` is the process that sleeps; the 1 s and 3 s waits are not a new interpreter each time. Disk status and the heartbeat read used to be a `python -c` per poll. They are in-process now (`disk_status`, `recent_disk_heartbeat`). `cmd_wait` adds one `wait-rc` after the subscriber returns (`delegate-job:1042`). The integer knobs above are once per source of `diff_collect.sh` or `loop_lock.sh`.
## 7. Portability
Added Python is stdlib, `from __future__ import annotations`, and `Optional` / `List`. It compiles with the 3.9.6 venv named in the brief. No `tomllib`, no `match` statement, and no `/proc` read (the `_alive` comment says `ps` is used). The wrapper diff adds no `mapfile`, `readarray`, `&>`, or associative array. The suite ran on this macOS with that interpreter and the system bash 3.2.57.
## Tests
`AGENT_PYTHON_BIN` is the pr-51 venv. MQTT variables and `MAM_ENV_FILE` were unset. `--basetemp=/tmp/c7eefollow.h4VCjP`. The nine files in the brief: **604 passed, 1 skipped** in 1006.68 s, exit 0. The skip is `tests/test_issue30_stall_watch.py:40`, real process trees via `/proc`, Linux only. Snapshot `git status` was empty after the run, and again after the l15 test. No product file was edited.
[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.8.5"
MAM_VERSION="4.8.6"
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.8.5"
FRAMEWORK_VERSION = "4.8.6"
CORE_MODULES: Sequence[str] = (
# Standard library modules
@@ -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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
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.8.5
version: 4.8.6
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
[![Version: v4.8.6](https://img.shields.io/badge/version-v4.8.6-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.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
[![Version: v4.8.6](https://img.shields.io/badge/version-v4.8.6-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.8.5
Applies to: MAM v4.8.6
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와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
[![Version: v4.8.6](https://img.shields.io/badge/version-v4.8.6-blue.svg)](VERSIONS.md)
---
+1 -1
View File
@@ -2,7 +2,7 @@
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
[![Version: v4.8.5](https://img.shields.io/badge/version-v4.8.5-blue.svg)](VERSIONS.md)
[![Version: v4.8.6](https://img.shields.io/badge/version-v4.8.6-blue.svg)](VERSIONS.md)
---
+32 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.8.5`
- **최신 릴리스 일시**: 2026-10-04 (KST)
- **프레임워크 버전**: `v4.8.6`
- **최신 릴리스 일시**: 2026-10-08 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -24,23 +24,44 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.8.5`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.8.6`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.8.5` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.8.5` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.8.5` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.8.5` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.8.5` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.8.5` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.8.5` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.8.5` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.8.6` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.8.6` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.8.6` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.8.6` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.8.6` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.8.6` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.8.6` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.8.6` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.8.6` — Issue #17 항목 4: delegate-job 래퍼의 판정이 Python 으로 옮겨진다 (2026-10-08)
> **패치 (PATCH Release)**: 동작 변화 없는 리팩터링. 래퍼(bash)의 판정 로직을 `lib_py/delegation/wrapper.py` 로 옮기고, #26 정수 규칙의 두 구현을 `lib_py/ints.py` 하나로 합쳤다. 종료 코드·라벨·레지스트리 기록·이벤트·멈춤 감시 동작은 같다. #17 의 나머지 항목은 열린 채 남는다.
#### 🌟 주요 개선 사항
1. **판정의 이동 (`lib_py/delegation/wrapper.py`)**: `finalize_unfinished`(시계 선택, 디스크 heartbeat 유예, 브로커 재관측, `wall_clock_timeout`/`deadline_timeout`/`cancelled` 라벨, close 와 이벤트) → `finalize-begin`/`finalize-end`, `start_stall_watch` 의 감시 여부 → `stall-watch`, `_map_sub_rc` → `sub-status`, `_rc_for`(+`MAM_DELEGATE_FAIL_CLOSED`) → `exit-code`, 구독자 조기 종료 검사 → `subscriber-up`, `cmd_wait` 의 rc 정규화 → `wait-rc`. 래퍼는 `_decide` 하나로 부르고 서브커맨드의 종료 코드나 한 줄 출력에만 분기한다.
2. **정수 규칙 하나 (`lib_py/ints.py`)**: `lib_int.sh` 의 `mam_int`/`mam_int_knob` 와 runner 의 `_positive_int` 가 같은 구현(`positive_int`, `knob`)을 부른다.
3. **bash 에 남는 것**: spawn·wait·kill, 키 주입, 시그널 정리, 진단용 herdr busy 확인 하나, CLI 호환 층.
4. **finalizer 의 수명 (PR #41 계약과 같게)**: `finalize-end` 는 `--watch-pid "$$"` 를 받아 두 대기 내내와 close·이벤트 직전에 래퍼를 확인하고, 래퍼가 없으면 닫지도 발행하지도 않고 끝난다. 종료했지만 회수되지 않은 래퍼(좀비, `ps -o stat=` 의 Z)도 없는 것으로 본다.
5. **리뷰 R1 (병합 워크스페이스, codex)**: `finalize-end` 의 대기(`_sleep`)가 벽시계로 길이를 재서, 대기 중 시계가 뒤로 가면 bash `sleep N` 과 달리 늘어났다(10 초 뒤로 → 1 초 대기가 11 초). 대기 길이는 monotonic 시계로 재고 마감 판정은 벽시계에 남긴다(4c86107, 회귀 테스트 `test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back`).
#### ✅ 품질 검증
- **옛 bash 대 새 래퍼 (`tests/test_issue17_item4_decisions.py`)**: 7209750 의 bash 함수를 그대로 복사한 `tests/data/issue17_item4_old_decisions.sh` 와 새 래퍼를 같은 입력에 돌려 출력·`FINAL_JOB_STATUS`·레지스트리/발행 호출을 비교한다(각 유예 중 TERM, 회수 없는 TERM 포함). 판정표 전체·잘못된 입력·경계값 단위 테스트.
- **독립 대조 (오케스트레이터)**: 7209750 대 518beda 실제 CLI, 오프라인으로 179 경우 모두 종료 코드·출력 일치(`mam_int`/`mam_int_knob` 19 입력, `submit --dry-run` 정수 플래그 6 개 × 19 입력, 8 상태의 레코드에 대한 `wait`).
- **리뷰 (병합 워크스페이스, 네 리뷰어)**: 1회차(@518beda) claude·agy·grok PASS, codex NOT PASS(R1 위 5). 4c86107 에서 수정. 2회차 델타(@4c86107) claude·agy·grok PASS. codex 는 첫 잡(817d1650)이 판정 없이 `deadline_timeout` 으로 닫혀 다시 돌렸고(24b21b9c), 그 리포트는 "R1 닫힘, 새 코드 결함 없음"이지만 자기 샌드박스가 루프백 브로커의 bind 를 막아 필수 두 파일 스위트(278 건)를 실행하지 못했다는 이유로만 NOT PASS 를 냈다. 같은 스위트를 병합 워크스페이스에서 4c86107 로 돌려 **278 passed** 를 확인하고 병합했다. 리포트는 `.agents/reports/<session>/`.
- **전체 스위트 (병합 워크스페이스, macOS·Python 3.9, MQTT 환경변수 없이)**: 518beda **1711 passed / 1 failed / 38 skipped**(72 분). 실패한 `tests/test_issue45_restore.py::test_b_only_the_resumed_sessions_restored_shell_is_closed` 는 main(7209750)에서도 실패한다: herdr 0.9.3 은 지워진 cwd 의 pane 을 `$HOME` 이 아니라 원래 경로로 복원해 테스트의 전제가 바뀌었다(이 PR 과 무관). 4c86107 의 델타는 관련 테스트 321 passed.
#### 📝 후속
- herdr 0.9.3 에 맞춘 `test_issue45_restore` 의 cwd 전제 수정.
- 래퍼 모듈이 패키지 경유로 paho 를 import 하는데 문서는 "stdlib only" 라고 한다; `_decide` 실패 시 `exit ""` 가 255 가 된다(`${rc:-1}` 제안); `lib_int.sh` 가 PATH 의 `python3` 를 쓴다; PR 본문은 0.2 초, 코드는 0.5 초 간격(claude 1회차 비차단 지적 N1·N2·N3·N5).
### 🚀 `v4.8.5` — Issue #45 A·B: 재기동된 격리 서버가 저장된 에이전트를 다시 띄우지 않고, resume 이 복원된 빈 셸을 닫는다 (2026-10-04)
> **패치 (PATCH Release)**: 격리 herdr 서버가 에이전트를 정지하지 않은 채 꺼졌다 다시 뜨면, herdr 가 저장 레이아웃을 복원하며 에이전트를 자기 재개 명령으로(권한 플래그 없이, 지운 에이전트까지) 다시 띄웠다. herdr 0.9.1 의 `[session] resume_agents_on_restore = false` 로 MAM 이 띄우는 서버에서 이를 끈다(A). 끈 뒤에도 pane 은 빈 셸로 복원되므로, resume 이 그 세션 자신의 빈 셸을 닫는다(B). 이슈의 제안 2·4 는 범위 밖.