merge: fix/issue17-followups (Issue #17 follow-ups: a failed decision ends the wrapper with 1; resume closes herdr 0.9.3's placeholder; v4.8.7)
Refs #17 (#17 stays open). 4/4 PASS in the merge workspace; see VERSIONS.md v4.8.7.
This commit was merged in pull request #52.
This commit is contained in:
commit
6b00b6e06f
37 files changed
+861
-58
No files matched your search
@@ -0,0 +1,43 @@
|
||||
# 리뷰 리포트 — Job 4566e690 (Issue #17 후속, fix/issue17-followups 56d0bfd..8241bd5)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 8241bd5 / tree efef640 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 커밋 6개: 25c9970·f19f940(항목 1), a670ef5(항목 2), d6cb019(항목 3), c90a0af(항목 4), 8241bd5(테스트 환경 고정). 버전·VERSIONS.md 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
항목 1~4 가 요구대로 반영됐고, 정상 경로의 종료 코드·출력·레지스트리·이벤트는 변하지 않았다. 추가 테스트 13건은 수정 전 코드에서 실제로 실패한다(255·127·2 등 예측된 코드로). 항목 1 에는 브리프 문구("테스트 전제 수정")를 넘는 **제품 코드 변경**(herdr_restore.py 의 placeholder 닫기)이 포함되는데, 실제 herdr 0.9.3 에서 그 변경 없이는 테스트도 resume 의 닫기도 성립하지 않음을 확인했으므로 정당한 범위 확장으로 판단한다(§1). 차단 결함 없음. 비차단 관찰 3건(§6).
|
||||
|
||||
## 1. 항목 1 — herdr 0.9.3 과 test_b
|
||||
- 브리프의 전제 "닫기 동작은 cwd 를 보지 않는다 → :275 만 두 경로 허용" 은 불완전했다. herdr 0.9.3 은 디렉터리가 사라진 pane 을 **프로세스 없는 placeholder**(`restore_error` 설정)로 복원하고 `pane process-info` 는 `pane_not_found` 로 rc 1 을 낸다. 실제 서버 대조: 스크래치 사본에 **이전 herdr_restore.py** 만 넣고 test_b 를 돌리면 `close-restored-shells: Command […'pane','process-info','--pane','w2:p1'] returned non-zero exit status 1` 로 실패(1 failed, 11.8 s) — 즉 옛 코드는 Y 의 placeholder 에서 예외를 내 resume 의 닫기 단계 전체가 rc 1 로 끝나고(래퍼는 경고만), 아무 pane 도 닫지 못한다.
|
||||
- 수정(herdr_restore.py:58-66 `_nothing_running`): `_shell_in_foreground` 가 CalledProcessError 를 내면 **pane 에 `restore_error` 가 있고 출력에 `pane_not_found` 가 있을 때만** "실행 중인 것 없음"으로 보고, 그 외 실패는 그대로 올린다. 두 조건이 모두 herdr 자신이 붙인 표지라 사용자 pane·다른 세션 pane 이 이 경로로 닫힐 수 없다(agent_session 일치 조건 :83 은 그 앞에서 여전히 적용). 단위 테스트 2건이 "restore_error 없음 + pane_not_found", "restore_error 있음 + 다른 오류" 를 모두 예외 유지로 고정한다. fail-safe 방향(닫는 쪽이 아니라 두는 쪽) 유지.
|
||||
- `_herdr` 는 `subprocess.run(..., capture_output=True, check=True)` 이므로 CalledProcessError.stderr/.output 이 채워진다(:46-49) — 판정에 쓰는 문자열이 실제로 들어온다.
|
||||
- test_issue45_restore.py:267·:275-277 주석·단언이 0.9.1($HOME)/0.9.3(원래 경로) 둘 다 허용. 다른 문서의 "$HOME 으로 복원" 주장: SKILL.md 들에는 없음(create:120, resume:63 은 "empty shells" 만 말함), VERSIONS.md 과거 항목은 브리프대로 제외.
|
||||
- 실행: 이 Mac(herdr 0.9.3)에서 tests/test_issue45_restore.py **5개 모두 통과, skip 0**.
|
||||
|
||||
## 2. 항목 2 — N2 fail-closed
|
||||
- 래퍼(:103-127): `_decision_failed` 는 stderr 에 사유를 적고 `exit 1`; `_decided <var> <sub> …` 는 rc≠0 또는 빈 출력이면 실패; `_exit_for` 는 답이 `^[0-9]+$` 가 아니면 실패. 다섯 `exit "$(_decide exit-code …)"` 가 `_exit_for` 로, 네 `sub-status` 대입과 `finalize-begin` 이 `_decided` 로, `finalize-end` 는 rc 검사(:856-857), `cmd_wait` 의 wait-rc 는 `_decided` + 숫자 검사(:1070-1071). 모든 호출이 서브셸 밖(직접 호출)이라 `exit 1` 이 래퍼를 끝낸다; `{ …; } 4>&1` 그룹도 서브셸이 아니다.
|
||||
- 정상 경로 불변: `_decided` 는 같은 명령의 같은 한 줄 출력을 변수에 넣고, `finalize-end` 의 "소유자 사라짐 → rc 0·빈 답" 은 전과 같이 FINAL_JOB_STATUS="" 로 남는다(의도된 기존 동작). test_f_the_normal_path_is_unchanged(실제 래퍼, human 잡, 닿지 않는 브로커 → rc 3, 마지막 줄 = 로그 경로, 오류 문구 없음) 통과. 옛·새 비교 테스트(test_c_*) 전부 통과.
|
||||
- rc 로만 읽는 호출: `subscriber-up` 실패(모듈 깨짐) → "subscriber died early" 경로 → `_decided sub-status` 가 실패 → exit 1(fail-closed, OK). `stall-watch` 실패 → `|| return 0` 으로 **감시만 꺼진 채 진행**(:718). 브리프가 "범위에 넣지 않은 것은 이유를 적으라" 했는데 리뷰어에게 그 이유는 보이지 않는다 — 관찰 N1. #30 감시자는 notify-only 라 잡 종결에는 영향이 없어 비차단.
|
||||
- 추가 테스트가 수정 전 코드에서 실패하는가: 이전 래퍼(56d0bfd)를 넣은 사본에서 test_f_* 11건 + test_s_no_printed_decision_is_read_raw 모두 **실패** — exit-code 는 `exit: : numeric argument required` 로 **255**, 인터프리터 부재는 **127**, finalize-begin 빈 답은 finalize-end argparse `--evidence-until: invalid int value: ''` 로 **2**. 세 가지 모두 브리프가 금지한 종료 방식이며 새 코드는 전부 1 로 끝난다.
|
||||
- 이식성: `/bin/bash -n` 통과(3.2), `printf -v`·`[[ =~ ]]` 는 3.2 에서 지원. 테스트는 `/bin/sh` 셸 스크립트 대역과 `sys.executable` 만 쓴다.
|
||||
|
||||
## 3. 항목 3 — N1 문구
|
||||
wrapper.py:24-29 가 "이 모듈은 stdlib 만 import 하지만 `python -m lib_py.delegation.wrapper` 는 패키지 `__init__` 을 먼저 실어 paho 가 로드되며, 래퍼의 $PY 는 원래 paho 가 필요하다"로 정정. 실행 경로 변경 없음(diff 상 docstring 만).
|
||||
|
||||
## 4. 항목 4 — N3 주석
|
||||
lib_int.sh:9-10 에 "PATH 의 python3 를 쓴다(단독 source 라 $PY 없음); 없으면 mam_int/mam_int_knob 이 아무것도 출력하지 않는다(rc 127)" 추가. 코드 불변.
|
||||
|
||||
## 5. 테스트 실행 (검증 경로, 워크트리 .venv Python 3.9.6, paho 있음, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| tests/test_issue45_restore.py, test_issue45_restore_unit.py, test_issue17_item4_decisions.py, test_issue33_budget.py | **310 passed / 0 failed / 0 skipped** (425 s) |
|
||||
| 새 테스트 16건, 이전 래퍼·이전 herdr_restore.py 사본 | **13 failed** / 3 passed (실패 = F 11 + 정적 가드 + placeholder 단위) |
|
||||
| test_b (실서버), 이전 herdr_restore.py 사본 | 1 failed (pane_not_found 예외) |
|
||||
격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach·오케스트레이터 `agent wait`(pid 만 교체)뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml md5 불변. 실제 .mam/jobs 항목 수 불변. 루프백 bind 제약 없음(브로커 테스트 정상 실행).
|
||||
|
||||
## 6. 비차단 관찰
|
||||
- **N1 stall-watch 판정 실패 시 조용히 감시 생략**(래퍼 :718 `|| return 0`): 모듈이 깨지면 stderr 에 아무 말 없이 stall watcher 가 꺼진다. 종결·레지스트리·이벤트에는 영향이 없어 fail-closed 요구 밖이지만, `_decide` 의 rc 가 1(판정 "감시 안 함")인지 그 밖(실패)인지 구분해 후자는 경고 한 줄을 남기면 진단이 쉬워진다. 브리프가 요구한 "범위 제외 이유" 가 리포트에 있는지 오케스트레이터가 확인하길 권한다.
|
||||
- **N2 `_env` 가 상속 WORKSPACE_ROOT 를 비우지 않음**(tests/test_issue17_item4_decisions.py:224-230): HERDR_/MAM_/SKS_ 만 걷어낸다. 새 테스트(:659-660, :692)는 WORKSPACE_ROOT 를 명시하지만, 래퍼를 source 하는 기존 `_run_both` 4곳(:246, :372, :561, :586, PR #51 유래)은 `_env(work, …)` 만 써서 에이전트 pane 에서 돌리면 그 pane 의 WORKSPACE_ROOT 로 lib.sh 가 초기화된다 — 브리프가 적은 사고(상속된 WORKSPACE_ROOT 로 다른 워크스페이스의 shim 재작성)와 같은 경로다. `_env` 에서 WORKSPACE_ROOT 를 제거하고 tmp_path 로 고정하면 한 줄로 막힌다. 이 델타가 만든 문제가 아니라 비차단.
|
||||
- **N3 docstring 줄바꿈**(herdr_restore.py:7-8): "A pane keeps the\n`agent_session` its agent\nreported" 로 어색하게 끊겨 있다. 내용 문제 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261007T224638Z-cf1efb",
|
||||
"base_sha": "56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3",
|
||||
"head_sha": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"tree_sha": "b805d9f9e6639c7359115ea9336eac95d7ab85bc",
|
||||
"review_commit": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "f0f131cd",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T01:16:15Z"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# 리뷰 리포트 — Job f0f131cd (Issue #17 후속 2회차 델타, 8241bd5..65b78b6)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 65b78b6 / tree b805d9f — 검증 경로(…/2)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 2개 — 541c3fe(agy 지적: 동결 리터럴을 `_decided`/`_exit_for` 로), 65b78b6(codex R1: 실패한 yes/no 판정은 "no" 가 아니다). wrapper.py +24/−4, 래퍼 +18/−4, 테스트 3개. 버전·VERSIONS·문서 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
codex R1 이 닫혔고, 그 수정이 1회차에 내가 남긴 N1(stall-watch 판정 실패 시 조용히 감시 생략)도 함께 닫는다. 정상 경로(yes/no 두 분기 모두)의 동작은 불변. 새 테스트 15건이 1회차 head 코드에서 실제로 실패하며, 8건은 codex 가 지적한 바로 그 증상(rc 0)으로 실패한다. 차단 결함 없음.
|
||||
|
||||
## 1. 지적 종결
|
||||
| 지적 | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| codex R1: `_decide subscriber-up`/`stall-watch` 를 rc 로 읽어, 판정 **실패**(rc 1·127, 빈 답)가 "no" 와 구분되지 않음. subscriber-up 에서는 rc 0 으로 끝난 구독자가 "not up" → "died early" 경로 → sub-status 0 = completed → **submit 이 0 으로 종료** | wrapper.py:8-17, :335-340, :344-353 — 두 yes/no 서브커맨드에 `--print`(yes/no 출력 + rc 0). 래퍼 :121-131 `_decided_yes` 가 `_decided` 로 답을 받아 `yes`/`no` 외에는 `_decision_failed` → exit 1. 호출 3곳(:396, :568, :730) 교체 | 1회차 head(8241bd5) 래퍼+wrapper.py 를 넣은 사본에서 test_f_a_failed_yes_no_decision_ends_the_submit_with_1 9건 **모두 실패** — subscriber-up 6건은 `assert 0 == 1`(codex 의 증상 그대로), stall-watch 3건은 오류 문구 없이 정상 종료. 현재 코드에서는 9건 모두 rc 1 + "could not decide" |
|
||||
| claude N1(1회차): stall-watch 판정 실패가 `\|\| return 0` 으로 조용히 감시만 끔 | 위와 같음 — "no" 는 여전히 `return 0`(감시 안 함), 실패는 exit 1 | stall-watch-direct-{error,gone,empty} 통과 |
|
||||
| agy: test_issue5_phase1_delegation·test_tier1_unit 의 동결 리터럴이 옛 `_decide sub-status`/`exit "$(_decide exit-code` 문자열을 가리킴 | `_decided job_status sub-status "$sub_rc"`, `_exit_for "$job_status"` 로 갱신 | 두 파일 통과(아래) |
|
||||
|
||||
## 2. 정상 경로 불변
|
||||
- rc 형식은 그대로 남아 있다(`--print` 없이 subscriber-up 1 → rc 1 확인; test_u_yes_no_print 가 두 형식을 함께 고정).
|
||||
- test_f_yes_and_no_keep_their_branches(direct·loop × up/not-up): "yes" 는 진행, "no" 는 "subscriber died early" 경로로 가고 direct 의 종료 코드는 0/1 — 옛 코드에서도 통과(4 passed)하고 새 코드에서도 통과한다 = 분기 결과 불변. 옛·새 비교 테스트(test_c_*) 전부 통과.
|
||||
- `_decided_yes` 는 `if …; then`(:396, :568)과 `… || return 0`(:730) 안에서 직접 호출되며 서브셸이 아니므로 `exit 1` 이 래퍼를 끝낸다. `--print` 를 위치 인자 뒤에 붙여도 argparse 가 받는다(3.9 에서 직접 확인: `stall-watch remote:r claude --print` → stderr 진단 + stdout `no`, rc 0).
|
||||
- `stall_watch` 의 진단 메시지는 전과 같이 stderr 로만 나가 stdout 의 한 줄 답을 더럽히지 않는다.
|
||||
|
||||
## 3. 정적 가드
|
||||
test_s_no_printed_decision_is_read_raw 가 `_decide (subscriber-up|stall-watch)` 의 rc 읽기 재유입을 막고 `_decided_yes` 호출 수(2+1)를 고정한다. 옛 코드에서 실패, 새 코드에서 통과.
|
||||
|
||||
## 4. 이식성
|
||||
`/bin/bash -n`(3.2.57) 통과. 추가된 bash 는 `case`·`local`·함수 호출뿐. wrapper.py 변경은 argparse `store_true` 와 print.
|
||||
|
||||
## 5. 테스트 실행 (검증 경로 …/2, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| test_issue45_restore, test_issue45_restore_unit, test_issue17_item4_decisions, test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **461 passed / 0 failed / 0 skipped** (537 s; 실서버 restore 5건 포함, herdr 0.9.3) |
|
||||
| 새 테스트 19건, 1회차 head 코드 사본 | **15 failed** / 4 passed (실패 = yes/no 실패 9 + `--print` 단위 5 + 정적 가드; 통과 4 = 분기 불변 테스트, 의도대로) |
|
||||
격리: 실행 전후 herdr 프로세스 차이는 오케스트레이터의 `agent wait` pid 교체뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml 불변, 실제 .mam/jobs 항목 수 불변.
|
||||
|
||||
## 6. 비차단
|
||||
- 1회차 N2(`_env` 가 상속 WORKSPACE_ROOT 를 비우지 않아 `_run_both` 4곳이 에이전트 pane 의 워크스페이스로 lib.sh 를 초기화할 수 있음)와 N3(herdr_restore.py docstring 줄바꿈)는 이 델타에서 다루지 않았다. 둘 다 이 PR 이 만든 문제가 아니며 그대로 비차단.
|
||||
- 새 관찰 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Issue #17 follow-ups — Codex review
|
||||
|
||||
Job: 70d302c2
|
||||
Loop: loop-20261007T224638Z-cf1efb
|
||||
Base: 56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3
|
||||
Head: 8241bd53b179cf8332541264c3bf12b8e3eca44a
|
||||
Tree: efef640d36f6666f107e188d554c39f05400d4ac
|
||||
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/1
|
||||
|
||||
## Finding R1 — subscriber-up decision failures can still report successful submission (P2)
|
||||
|
||||
Evidence: .agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:384-395 (direct), :556-567 (loop/discuss); .agents/skills/lib_py/delegation/wrapper.py:332-333.
|
||||
|
||||
The new failure handling only protects printed answers. An execution failure in the subscriber-up predicate is consumed as a normal false answer. If the subscriber has already exited 0, the error branch maps that code to completed, finalize-begin keeps it, and _exit_for exits 0. This violates N2's requirement that a failed decision call ends the wrapper with 1, never 0.
|
||||
|
||||
I read the exclusion rationale in commit a670ef5: it states that subscriber-up failure treats the subscriber as dead and reaches a fail-closed sub-status/exit-code path. Those later calls can succeed and report completed, so the rationale does not cover a failure confined to the earlier process invocation. This is the same per-decision failure-injection model used by the added regression tests, not an assumption that every later Python invocation must also fail.
|
||||
|
||||
Reproduction (no broker or agent session):
|
||||
1. Use tests/test_issue17_item4_decisions.py::_failing_py with decision="subscriber-up", mode="error" (or "gone").
|
||||
2. Set SUB_STANDIN=1 and SUB_RC=0. The shim makes only job_subscriber.py return 0 and only the targeted predicate fail; other Python calls remain real.
|
||||
3. Call _human_submit with a temporary HOME/workspace/registry and that shim as $PY.
|
||||
4. Expected: submit exits 1 with a decision-failure diagnostic. Actual: module traceback rc 1 -> submit rc 0; missing-interpreter rc 127 -> submit rc 0. The wrapper prints "subscriber died early (... exit=0 ...)" and a log path, then returns success.
|
||||
|
||||
Scripts: /tmp/70d302c2-probes.py and /tmp/70d302c2-predicate-errors.py. All created job records are in temporary probe directories, not the real registry.
|
||||
|
||||
Fix direction: distinguish a successfully computed boolean from inability to execute the decision. A printed boolean through the existing _decided machinery (possibly a new flag/subcommand retaining the old CLI contract) can return execution status 0 for both valid answers while preserving the normal true/false branches in Bash. Treat nonzero execution status or missing/invalid output as _decision_failed. Do not simply fail on every current rc 1, since false legitimately uses that code. At minimum, rc values outside 0/1 must never be silently treated as false, but that alone cannot fix the reproduced import-error rc 1 case. Add tests for both true and false predicates, plus selectively failing subscriber-up with subscriber rc 0, in both direct and loop/discuss call sites.
|
||||
|
||||
The same ambiguity exists at :718 for stall-watch: a probe returning 127 yielded caller rc 0 and continued execution ("survived"), silently disabling the watcher. The creator explicitly describes the watcher as optional/notify-only; this observation is related evidence, while the submission-success counterexample above is sufficient for NOT PASS.
|
||||
|
||||
## Other requirements
|
||||
|
||||
- Restore premise: tests/test_issue45_restore.py now accepts HOME or the original removed directory. The source docstring distinguishes herdr 0.9.1/0.9.3. The additional production handling for restore_error + pane_not_found is explained by commit f19f940: merely relaxing cwd exposed a placeholder with no process, so closing previously raised before reaching other candidates. The matching session, no-live-agent, non-default-server and normal foreground-shell filters remain.
|
||||
- Restore regression negative control: executed the new placeholder unit-test function against the base herdr_restore implementation using a stubbed _herdr; it raises CalledProcessError at base and passes at head.
|
||||
- Printed decisions: the introduced _decided/_exit_for checks cover exit-code, sub-status, finalize-begin, wait-rc and finalize-end process failures. Allowing empty finalize-end output is intentional for OwnerGone, whose CLI handler returns normally without a printed decision. Bash 3.2 dynamic scoping makes printf -v assign the intended caller variables.
|
||||
- Exit-code negative control: the exact base wrapper placed in a temporary dependency layout with the same failure shim exits 255, while head exits 1. This confirms the added failure test actually detects the original bug.
|
||||
- N1 accurately documents the package import path to paho; execution code did not change.
|
||||
- N3 accurately documents PATH python3 and missing-interpreter behavior; executable code did not change.
|
||||
- Six item-scoped commits, seven changed files, no version bump or VERSIONS entry. No further code defect was identified in the reviewed normal-path handling.
|
||||
|
||||
## Verification
|
||||
|
||||
- Python 3.9 from the designated issue17-followups/.venv; native /bin/bash 3.2.57.
|
||||
- With --noconftest, cache disabled, external basetemp and per-test temporary WORKSPACE_ROOT: tests/test_issue17_item4_decisions.py + tests/test_issue45_restore_unit.py -> **281 passed, 4 failed in 292.91s**. All newly added failure-handling and restore unit tests passed. Log: /tmp/70d302c2-selected.log.
|
||||
- The four failures are existing zombie-owner tests: test_u_an_exited_unreaped_owner_is_not_alive, both test_c_finalize_ends_with_an_unreaped_wrapper parameters, and test_c_an_unreaped_wrapper_leaves_the_real_record_alone. A separate owned-child probe confirmed ps execution raises PermissionError(1, Operation not permitted), causing the existing _alive fallback to return True for an unreaped exited child. The wrapper.py executable AST is identical to base (only its module docstring changed). These sandbox-caused failures are not a new finding against this PR.
|
||||
- Budget tests with --noconftest and selection "not test_b3 and not test_r3 and not test_b7": **13 passed, 7 deselected in 28.88s**. Log: /tmp/70d302c2-budget.log. This exercises budget propagation, child wait caps, remote deadline labels and closed-unconfirmed classification without needing a reachable broker.
|
||||
- Explicit loopback bind probe failed with PermissionError(1, Operation not permitted). Consequently no full broker suite result is claimed. Real-herdr restore integration was not run in this sandbox; the orchestrator retains that validation responsibility. Static inspection of its rig confirms disposable HOME/HOME_DIR, mamt45-* session names, scrubbed inherited server selectors and fake agent commands, rather than the user's server/config.
|
||||
- Both shell files pass /bin/bash -n. All five changed Python files pass AST parsing. git diff --check BASE HEAD passes.
|
||||
- HEAD and tree match the brief; final git status --porcelain is empty.
|
||||
- Independent probes reproduced subscriber-up failures returning submit 0 for both module-error rc 1 and interpreter-error rc 127, plus swallowed stall-watch rc 127.
|
||||
- Negative controls confirmed exit-code failure base 255 -> head 1 and placeholder regression base exception -> head pass.
|
||||
- The environment restrictions alone do not determine the verdict. R1 is an independently reproduced N2 requirement violation.
|
||||
|
||||
## Isolation and lifecycle
|
||||
|
||||
All verification ran from the specified review tree. Inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane bindings were cleared; temporary HOME/HOME_DIR/WORKSPACE_ROOT, XDG_CONFIG_HOME and an absent MAM_ENV_FILE were supplied. Tests sourcing lib.sh received a per-test temporary WORKSPACE_ROOT via an environment-only pytest plugin. Direct probes used synthetic jobs and temporary registries. No .mam.env was exported, no live herdr or agent session was used, and no creator/source files, commits or branches were changed. Base comparison artifacts and logs were outside the review tree.
|
||||
|
||||
The requested started publication was attempted first; MQTT exhausted three attempts with Operation not permitted and exited 2. The requested completed command was also executed after saving the report; all three MQTT attempts failed with Operation not permitted and it exited 2. The report exists, but broker delivery of started/completed is not confirmed.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261007T224638Z-cf1efb",
|
||||
"base_sha": "56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3",
|
||||
"head_sha": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"tree_sha": "b805d9f9e6639c7359115ea9336eac95d7ab85bc",
|
||||
"review_commit": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "fc553fcd",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T01:16:15Z"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Issue #17 follow-ups, round 2 — Codex review
|
||||
|
||||
Job: fc553fcd
|
||||
Loop: loop-20261007T224638Z-cf1efb
|
||||
Base: 56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3
|
||||
Previous round: 8241bd53b179cf8332541264c3bf12b8e3eca44a
|
||||
Head: 65b78b6ec44e44904afe34915563522d4631767f
|
||||
Tree: b805d9f9e6639c7359115ea9336eac95d7ab85bc
|
||||
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/2
|
||||
|
||||
## Conclusion
|
||||
|
||||
PASS. The prior R1 failure-to-success path is closed. No new blocking defect was found in the cumulative change or the two follow-up commits. This review uses the brief's permitted network-free validation; it does not claim full broker or real-herdr integration execution in this sandbox.
|
||||
|
||||
## Previous finding closure
|
||||
|
||||
| Case | Previous round | Current head | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| subscriber-up execution error, direct submit, subscriber rc 0 | submit exits 0 | submit exits 1 with decision-failure diagnostic | R1 closed |
|
||||
| Same error in loop submit | submit exits 0 | submit exits 1 | R1 closed |
|
||||
| stall-watch execution error | submit exits 0 | submit exits 1 | Related ambiguity closed |
|
||||
| Successful yes/no answers | Truth encoded in process status | --print emits yes/no with process status 0; Bash restores the same branches | Normal semantics preserved |
|
||||
| Invalid successful predicate stdout in if / OR context | No printed-answer validation | Exit 1, no continuation | Guard verified |
|
||||
|
||||
Independent negative controls executed the exact previous-round shell wrapper in a temporary dependency layout and the current wrapper against the same selective module-error shim. All three old/new pairs above were reproduced. The added tests also cover rc 127 and empty successful output. Probe source: /tmp/fc553fcd-probes.py.
|
||||
|
||||
Code evidence:
|
||||
- .agents/skills/lib_py/delegation/wrapper.py:301-309 adds optional --print; :335-353 separates yes/no output from execution status. Without --print, the existing predicate CLI still returns 0/1.
|
||||
- .agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:112-130 checks execution status, nonempty output and the exact yes/no vocabulary. _decision_failed exits the calling shell explicitly, so the failure is not suppressed by Bash's conditional-context errexit rules.
|
||||
- Both subscriber branches now use _decided_yes (:396, :568), as does start_stall_watch (:730). The prior unchecked predicate paths are gone.
|
||||
|
||||
## Cumulative requirements and scope
|
||||
|
||||
The herdr cwd expectation accepts both documented versions; the placeholder handling retains session identity, live-agent and isolated-server filtering. Its unit cases include the valid placeholder, missing restore marker and another process-info error. The implementation and restore tests are unchanged since the prior review, where the new placeholder test was demonstrated to fail on base and pass on head.
|
||||
|
||||
Printed decision failures remain normalized to exit 1. The intentional empty finalize-end result for OwnerGone remains allowed. The prior base-vs-head exit-code negative control (255 -> 1) remains applicable because that helper is unchanged in this round.
|
||||
|
||||
N1's package-import/paho explanation and N3's PATH python3 explanation remain accurate, with no additional runtime changes. The two stale static test literals now match _decided/_exit_for; both corrected contract tests passed.
|
||||
|
||||
The round adds two commits and modifies five files relative to the prior head. AST comparison confirms wrapper.py's existing executable changes since that head are confined to _parser and main, plus the new _yes_no function; timing, owner checks, disk/event handling and finalization are unchanged. The cumulative diff contains nine files, no version bump or VERSIONS entry.
|
||||
|
||||
## Validation
|
||||
|
||||
- tests/test_issue17_item4_decisions.py and tests/test_issue45_restore_unit.py, --noconftest -k "not unreaped": **299 passed, 4 deselected in 262.30s**. Log: /tmp/fc553fcd-selected.log.
|
||||
- tests/test_issue33_budget.py, --noconftest -k "not test_b3 and not test_r3 and not test_b7": **13 passed, 7 deselected in 28.77s**. Log: /tmp/fc553fcd-budget.log.
|
||||
- The two modified static contract tests in test_issue5_phase1_delegation.py and test_tier1_unit.py: **2 passed in 0.16s**, using --noconftest and a minimal temporary mam_sandbox fixture.
|
||||
- Total executed pytest cases: **314 passed**; 11 explicitly deselected for the documented sandbox/integration limitations.
|
||||
- Independent previous/head predicate-failure comparisons and invalid-output probes: all assertions passed.
|
||||
- No product-test failures occurred among the executed cases.
|
||||
|
||||
All tests used the designated issue17-followups/.venv Python 3.9, disabled bytecode/cache writes and external basetemp directories. /bin/bash is the native 3.2 implementation. Both changed shell files pass bash -n; all changed Python files pass AST parsing; git diff --check passes. The review HEAD/tree matched the brief, and the final working tree was clean.
|
||||
|
||||
## Environment limits and isolation
|
||||
|
||||
The inherited sandbox prevents loopback socket binding and ps execution. As diagnosed in the prior round, the four unreaped-owner cases depend on ps; they were explicitly deselected here rather than reported as product failures. Broker-dependent budget cases and real-herdr restore integration remain for the orchestrator, as the brief permits. No claim is made that these omitted integration cases passed in this session.
|
||||
|
||||
Inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane binding variables were removed before test/probe execution. HOME, HOME_DIR, WORKSPACE_ROOT, XDG_CONFIG_HOME and absent MAM_ENV_FILE were redirected to temporary paths. The decisions suite used an environment-only pytest plugin to give every test its own WORKSPACE_ROOT. The two static contract tests used a minimal temporary fixture with a read-only link to the review skills and temporary registry data. No live herdr server, real agent session or .mam.env export was used. No source files, commits, branches, PRs or issue states were modified.
|
||||
|
||||
The requested started command ran first, but MQTT exhausted three attempts with Operation not permitted and exited 2. Completion publication is attempted after saving the report.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261007T224638Z-cf1efb",
|
||||
"base_sha": "56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3",
|
||||
"head_sha": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"tree_sha": "b805d9f9e6639c7359115ea9336eac95d7ab85bc",
|
||||
"review_commit": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "5a0c16dd",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T01:16:15Z"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# PR Review Report: Issue #17 Followups (Round 2 Review)
|
||||
|
||||
- **Reviewer Agent**: `agy` (session: `herdr:reviewer-agy-01`)
|
||||
- **Job ID**: `5a0c16dd`
|
||||
- **Verification Directory**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/2`
|
||||
- **Base Commit**: `56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3` (main)
|
||||
- **Head Commit**: `65b78b6ec44e44904afe34915563522d4631767f` (fix/issue17-followups)
|
||||
- **Cumulative Commits**: 8 commits (`25c9970` ~ `65b78b6`)
|
||||
- **Delta Under Inspection**: `8241bd5`..`65b78b6` (commits `541c3fe` and `65b78b6`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
In Round 1 (`67a528ff`), this reviewer returned `[VERDICT: NOT PASS]` due to test regressions in `tests/test_tier1_unit.py:847` and `tests/test_issue5_phase1_delegation.py:390` where frozen literal assertions still expected the pre-refactor `_decide sub-status` and `_decide exit-code` call signatures. Additionally, `codex` noted that boolean decisions (`subscriber-up` and `stall-watch`) that failed at runtime would mistakenly be treated as a negative decision (`"no"`) rather than triggering a fail-closed exit.
|
||||
|
||||
In this round, the Creator has completely resolved all issues across commits `541c3fe` and `65b78b6`:
|
||||
1. Updated `test_tier1_unit.py` and `test_issue5_phase1_delegation.py` to freeze the new helper signatures (`_decided job_status sub-status` and `_exit_for`).
|
||||
2. Implemented `--print` flag for boolean subcommands in `wrapper.py` and introduced `_decided_yes` in `multi-agent-mux-delegate-job`, ensuring failed yes/no decisions fail-closed with exit code 1.
|
||||
3. Added comprehensive test coverage in `tests/test_issue17_item4_decisions.py` verifying that failed boolean decisions exit with 1.
|
||||
|
||||
All tests across affected and regression suites executed cleanly (**656 passed, 1 skipped in 1147.89s**). The changes are surgical, robust, and fully conform to all behavioral invariance contracts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Resolution of Round 1 Findings Matrix
|
||||
|
||||
| Finding | Origin | Issue & Location | Resolution in Round 2 (`541c3fe`, `65b78b6`) | Status |
|
||||
|---|---|---|---|---|
|
||||
| **1. Frozen Literal Assertion in `test_tier1_unit.py`** | `agy` (Round 1) | `tests/test_tier1_unit.py:847` asserted `_decide sub-status "$sub_rc"`, which was replaced by `_decided` in `multi-agent-mux-delegate-job`. | Updated assertion to `_decided job_status sub-status "$sub_rc"` in commit `541c3fe`. | **CLOSED** (Verified) |
|
||||
| **2. Frozen Literal Assertion in `test_issue5_phase1_delegation.py`** | `agy` (Round 1) | `tests/test_issue5_phase1_delegation.py:390` asserted literals `_decide sub-status "$sub_rc"` and `_decide exit-code "$job_status"`. | Updated assertions to `_decided job_status sub-status "$sub_rc"` and `_exit_for "$job_status"` in commit `541c3fe`. | **CLOSED** (Verified) |
|
||||
| **3. Failed Boolean Decisions Passing as "No"** | `codex` (Round 1) | In bash, `_decide subscriber-up` or `_decide stall-watch` failing with rc > 0 evaluated as false (`"no"`), bypassing fail-closed error handling and potentially masking failures. | In commit `65b78b6`, added `--print` flag to `wrapper.py` (prints `"yes"`/`"no"` and exits 0) and added `_decided_yes` helper in bash wrapper to enforce fail-closed exit 1 on execution failure. | **CLOSED** (Verified) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Evaluation of Core Task Requirements
|
||||
|
||||
### 3.1. herdr 0.9.3 Compatibility (Item 1)
|
||||
- **`tests/test_issue45_restore.py:275`**: Accommodates both `herdr 0.9.1` (`$HOME` fallback) and `herdr 0.9.3` (retaining removed directory path with placeholder) via `assert _pane_with(rig, ids["Y"])["cwd"] in (str(rig.root), str(rig.root / "wsY"))`.
|
||||
- **`lib_py/herdr_restore.py:_nothing_running`**: Handles placeholders restored by `herdr 0.9.3` where `pane process-info` errors with `pane_not_found` and `restore_error` is set, allowing resume to cleanly close dead placeholder panes.
|
||||
- **`tests/test_issue45_restore_unit.py`**: Stubbed tests `test_a_placeholder_for_a_missing_directory_is_closed` and `test_any_other_process_info_failure_still_stops_the_run` verify that only authentic placeholders are closed, while unexpected errors continue to halt execution.
|
||||
|
||||
### 3.2. Fail-Closed on Decision Failure (Item 2 & N2)
|
||||
- **`_decision_failed()`**: Emits explicit error diagnostic to stderr and terminates with `exit 1`.
|
||||
- **`_decided()`**: Executes `_decide`, verifies non-zero exit and non-empty output, routing failures to `_decision_failed`.
|
||||
- **`_decided_yes()`**: Executes `_decide ... --print`, strictly validating that stdout contains `"yes"` (returns 0) or `"no"` (returns 1). Any execution error or unexpected output triggers `_decision_failed`.
|
||||
- **`_exit_for()`**: Validates that numeric exit code was returned, terminating with `exit "$code"`.
|
||||
- **`finalize_unfinished` & `cmd_wait`**: Checked return codes on `finalize-end` and validated numeric formatting on `wait-rc`.
|
||||
- **Coverage**: Verified via extensive unit and integration tests in `tests/test_issue17_item4_decisions.py` covering failure modes (`error`, `gone`, `empty`) across `exit-code`, `sub-status`, `finalize-begin`, `finalize-end`, `subscriber-up`, and `stall-watch`.
|
||||
|
||||
### 3.3. Documentation Updates (Items 3 & 4)
|
||||
- **`lib_py/delegation/wrapper.py:21-29`**: Accurately clarifies that while `wrapper.py` itself imports standard library modules only, executing as a module (`-m lib_py.delegation.wrapper`) triggers package initialization loading `paho-mqtt`.
|
||||
- **`lib_int.sh:9-11`**: Clearly documents that standalone sourcing executes `python3` from `PATH`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Comprehensive Test Suite Execution Results
|
||||
|
||||
All tests were executed directly in the review verification environment (`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/2`):
|
||||
|
||||
```bash
|
||||
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-followups/.venv/bin/python \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-followups/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider -q --basetemp=/tmp/pytest-issue17-r2 \
|
||||
tests/test_tier1_unit.py \
|
||||
tests/test_issue5_phase1_delegation.py \
|
||||
tests/test_issue17_item4_decisions.py \
|
||||
tests/test_issue45_restore.py \
|
||||
tests/test_issue45_restore_unit.py \
|
||||
tests/test_issue33_budget.py \
|
||||
tests/test_issue26_ints.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
|
||||
```
|
||||
|
||||
### Results Summary
|
||||
- **Total Tests**: 657
|
||||
- **Passed**: 656
|
||||
- **Skipped**: 1 (environment-specific non-root test)
|
||||
- **Failed**: 0
|
||||
- **Duration**: 1147.89s (0:19:07)
|
||||
- **Exit Code**: `0`
|
||||
|
||||
---
|
||||
|
||||
## 5. Conclusion & Final Verdict
|
||||
|
||||
All blockers from Round 1 have been completely resolved. The codebase is clean, robust, thoroughly covered by deterministic tests, and preserves 100% behavioral invariance.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,118 @@
|
||||
# PR Review Report: Issue #17 Followups (PR #51 Post-Merge Fixes)
|
||||
|
||||
- **Reviewer Agent**: `agy` (session: `herdr:reviewer-agy-01`)
|
||||
- **Job ID**: `67a528ff`
|
||||
- **Verification Directory**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/1`
|
||||
- **Base Commit**: `56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3` (main)
|
||||
- **Head Commit**: `8241bd53b179cf8332541264c3bf12b8e3eca44a` (fix/issue17-followups)
|
||||
- **Cumulative Commits**: 6 commits (`25c9970` ~ `8241bd5`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This review covers the Issue #17 follow-up work bundle addressing:
|
||||
1. `herdr 0.9.3` compatibility for removed pane directories (`test_issue45_restore.py`, `lib_py/herdr_restore.py`, `test_issue45_restore_unit.py`).
|
||||
2. N2: Fail-closed error handling for failed `_decide` calls (preventing `exit ""` -> rc 255 on bash 3.2).
|
||||
3. N1: Docstring clarification in `wrapper.py` regarding package-level paho import.
|
||||
4. N3: Comment update in `lib_int.sh` documenting `python3` PATH dependency.
|
||||
|
||||
While items 1, 3, and 4 are well-implemented and the new unit tests pass cleanly, **item 2 (commit `a670ef5`) introduces regressions in existing test suites** (`test_tier1_unit.py` and `test_issue5_phase1_delegation.py`). The commit replaced raw `_decide` subshell invocations with helper functions `_decided` and `_exit_for` in `multi-agent-mux-delegate-job`, but failed to update the frozen literal assertions in the existing test suite, causing 2 test failures.
|
||||
|
||||
Therefore, the verdict for this round is **NOT PASS**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Evaluation of Task Requirements
|
||||
|
||||
| Item | Description | Implementation Status | Assessment |
|
||||
|---|---|---|---|
|
||||
| **1. herdr 0.9.3 Compatibility** | `test_b_only_the_resumed_sessions_restored_shell_is_closed` failed on herdr 0.9.3 because removed pane directories retain their old path rather than resetting to `$HOME`. `lib_py/herdr_restore.py` must close placeholders with `restore_error` and `pane_not_found`. | `tests/test_issue45_restore.py:275`, `.agents/skills/lib_py/herdr_restore.py:130-138`, `tests/test_issue45_restore_unit.py:424-463` | **PASS**: Correctly handles both herdr 0.9.1 and 0.9.3 behavior; unit tests and full restore test pass without touching live sessions. |
|
||||
| **2. N2 Fail-Closed on `_decide` Failure** | When `_decide` fails, `exit "$(_decide exit-code ...)"` evaluated to `exit ""` (rc 255 in bash 3.2). The wrapper must exit 1 (fail-closed) on failure, never 255, 0, or empty string. | `multi-agent-mux-delegate-job:100-128`, `tests/test_issue17_item4_decisions.py:309-382` | **NOT PASS (Test Regression)**: Logic in `multi-agent-mux-delegate-job` is sound, but broke existing freeze tests in `test_tier1_unit.py` and `test_issue5_phase1_delegation.py`. |
|
||||
| **3. N1 Docstring Clarification** | `wrapper.py` docstring claimed "Stdlib only", but running via `-m lib_py.delegation.wrapper` loads `lib_py/delegation/__init__.py` and transitively requires `paho`. | `lib_py/delegation/wrapper.py:21-29` | **PASS**: Accurate docstring explanation added. |
|
||||
| **4. N3 `lib_int.sh` PATH Comment** | Document that `lib_int.sh` uses `python3` from PATH. | `lib_int.sh:9-11` | **PASS**: Comment added cleanly without code churn. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Failures & Detailed Findings
|
||||
|
||||
Running the regression suite in the review verification directory (`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/1`):
|
||||
```bash
|
||||
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-followups/.venv/bin/python \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-followups/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider -q --basetemp=/tmp/pytest-failed-inspect \
|
||||
tests/test_tier1_unit.py::test_g10_delegate_job_rc3_not_mistaken_for_error \
|
||||
tests/test_issue5_phase1_delegation.py::test_wrapper_exit_code_mapping_literals_are_frozen
|
||||
```
|
||||
|
||||
### Failure 1: `tests/test_tier1_unit.py:847`
|
||||
- **Test**: `test_g10_delegate_job_rc3_not_mistaken_for_error`
|
||||
- **Failure**:
|
||||
```python
|
||||
> assert '_decide sub-status "$sub_rc"' in delegate_script.read_text()
|
||||
E AssertionError: assert '_decide sub-status "$sub_rc"' in ...
|
||||
```
|
||||
- **Cause**: Commit `a670ef5` replaced line 405 (and line 574) of `multi-agent-mux-delegate-job`:
|
||||
```bash
|
||||
# Before (base 56d0bfd):
|
||||
job_status="$(_decide sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||
|
||||
# After (commit a670ef5):
|
||||
_decided job_status sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
|
||||
```
|
||||
Because `_decide sub-status "$sub_rc"` no longer appears literally in `multi-agent-mux-delegate-job`, the assertion in `test_tier1_unit.py:847` fails.
|
||||
|
||||
### Failure 2: `tests/test_issue5_phase1_delegation.py:390`
|
||||
- **Test**: `test_wrapper_exit_code_mapping_literals_are_frozen`
|
||||
- **Failure**:
|
||||
```python
|
||||
> for literal in ('_decide sub-status "$sub_rc"', '_decide exit-code "$job_status"', "--sub-rc \"$sub_rc\""):
|
||||
> assert literal in text, literal
|
||||
E AssertionError: _decide sub-status "$sub_rc"
|
||||
```
|
||||
- **Cause**: Commit `a670ef5` replaced all occurrences of:
|
||||
- `_decide sub-status "$sub_rc"` with `_decided job_status sub-status "$sub_rc"`
|
||||
- `exit "$(_decide exit-code "$job_status")"` with `_exit_for "$job_status"`
|
||||
Neither literal is present in the wrapper script anymore, causing the freeze verification in `test_issue5_phase1_delegation.py:390` to fail.
|
||||
|
||||
---
|
||||
|
||||
## 4. Required Fixes for Next Round
|
||||
|
||||
In worktree `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-followups`:
|
||||
|
||||
1. **Update `tests/test_tier1_unit.py:847`**:
|
||||
Change:
|
||||
```python
|
||||
assert '_decide sub-status "$sub_rc"' in delegate_script.read_text()
|
||||
```
|
||||
To match the new helper signature:
|
||||
```python
|
||||
assert '_decided job_status sub-status "$sub_rc"' in delegate_script.read_text()
|
||||
```
|
||||
|
||||
2. **Update `tests/test_issue5_phase1_delegation.py:390`**:
|
||||
Change:
|
||||
```python
|
||||
for literal in ('_decide sub-status "$sub_rc"', '_decide exit-code "$job_status"', "--sub-rc \"$sub_rc\""):
|
||||
assert literal in text, literal
|
||||
```
|
||||
To match the new helper signatures:
|
||||
```python
|
||||
for literal in ('_decided job_status sub-status "$sub_rc"', '_exit_for "$job_status"', "--sub-rc \"$sub_rc\""):
|
||||
assert literal in text, literal
|
||||
```
|
||||
|
||||
3. **Verify Full Test Suite**:
|
||||
Run:
|
||||
```bash
|
||||
AGENT_PYTHON_BIN=.venv/bin/python PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q tests/test_tier1_unit.py tests/test_issue5_phase1_delegation.py tests/test_issue17_item4_decisions.py tests/test_issue45_restore.py tests/test_issue45_restore_unit.py tests/test_issue33_budget.py
|
||||
```
|
||||
Confirm all tests pass without errors.
|
||||
|
||||
---
|
||||
|
||||
## 5. Final Verdict
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261007T224638Z-cf1efb",
|
||||
"base_sha": "56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3",
|
||||
"head_sha": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"tree_sha": "b805d9f9e6639c7359115ea9336eac95d7ab85bc",
|
||||
"review_commit": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "518e45d3",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T01:16:15Z"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Review: Issue #17 follow-ups @ 65b78b6
|
||||
|
||||
- Role: Reviewer (grok)
|
||||
- Identity: loop `loop-20261007T224638Z-cf1efb`, snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/2`, base `56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3` (main still `56d0bfd`), head `65b78b6ec44e44904afe34915563522d4631767f`, tree `b805d9f9e6639c7359115ea9336eac95d7ab85bc`. `8241bd5` is an ancestor. Snapshot porcelain empty. `git diff --check 56d0bfd..HEAD` clean.
|
||||
- Delta since the PASS at `8241bd5`: `541c3fe` and `65b78b6`, five files, +80/−16. Cumulative base..head: nine files, +267/−33. No version bump and no `VERSIONS.md` edit.
|
||||
- Verified read-only at the snapshot. Python 3.9.6 from the follow-up worktree `.venv`, bash 3.2.57, herdr 0.9.3. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, and `WORKSPACE_ROOT` were unset. `HOME` and `WORKSPACE_ROOT` were the basetemp. `.mam.env` was not exported. `started` for this dispatch is `dispatch_verified` with attempt-id 1.
|
||||
|
||||
## A failed yes/no decision exits 1
|
||||
|
||||
`wrapper.py` `_yes_no` (`:335-339`) prints `yes` or `no` and returns 0 when `--print` is set. Without `--print` it returns 0 for yes and 1 for no. `subscriber-up` and `stall-watch` both take `--print` (`:301-309`) and return through `_yes_no` (`:346-353`). The module head (`:15-17`) says the wrapper uses `--print`, so a failure to decide cannot pass for no.
|
||||
|
||||
`_decided_yes` (`multi-agent-mux-delegate-job:123-131`) calls `_decided` with `--print`. `yes` returns 0, `no` returns 1, and any other text calls `_decision_failed` (`:106-109`), which `exit 1`. `_decided` (`:112-118`) already exits 1 when the command status is non-zero or stdout is empty, before that case.
|
||||
|
||||
The handshake uses `if _decided_yes subscriber-up "$sub_exit"` at `:396` and `:568`. `start_stall_watch` (`:730`) is `_decided_yes stall-watch "$session" "$agent" || return 0`. A real `no` still returns 1, so the watcher is skipped and the subscriber "died early" branch still runs. A failed decision executes `exit 1` inside `_decision_failed`, so that `|| return 0` and the `if` leave the wrapper at 1.
|
||||
|
||||
`test_f_a_failed_yes_no_decision_ends_the_submit_with_1` (`tests/test_issue17_item4_decisions.py:693-699`) covers modes error, gone, and empty for subscriber-up on direct and loop submits, and for stall-watch on a direct submit. The stand-in subscriber exits 0 before `SUBSCRIBED`. Each run expects rc 1 and `could not decide '<decision>'`.
|
||||
|
||||
`test_f_yes_and_no_keep_their_branches` (`:702-710`) keeps the live branches. `SUB_RC` 0 stays on the yes path: "subscriber died early" is absent, and a direct submit exits 0. `SUB_RC` 1 takes the died-early path, and a direct submit exits 1. The direct rc 0 also shows that the later `wait "$sub_pid"` (`:437-438`), after the handshake already waited that pid, still finishes the submit on this bash 3.2.57.
|
||||
|
||||
`test_u_yes_no_print` (`:713-721`) checks `--print` stdout `yes`/`no` with rc 0, the rc form of subscriber-up still 0 or 1, stall-watch `herdr:c`/`claude` printing yes, and `remote:r`/`claude` printing no.
|
||||
|
||||
`test_s_no_printed_decision_is_read_raw` (`:734-743`) drops comment lines, then requires `_decided_yes subscriber-up` twice and `_decided_yes stall-watch` once, and rejects a raw `_decide subscriber-up` or `_decide stall-watch`. The comment at `:725` still names `_decide stall-watch`. The call that runs is `:730`.
|
||||
|
||||
## Frozen literals
|
||||
|
||||
`541c3fe` points `tests/test_issue5_phase1_delegation.py:390` and `tests/test_tier1_unit.py:847` at `_decided job_status sub-status "$sub_rc"`. The issue5 pin also requires `_exit_for "$job_status"`. `EXIT_CODES` and `sub_status()` stay asserted in Python (`test_issue5_phase1_delegation.py:393-395`).
|
||||
|
||||
## Items already passed at 8241bd5
|
||||
|
||||
The herdr 0.9.3 placeholder close, the N1 docstring, the N3 PATH comment, and the printed-decision `_decided` / `_exit_for` path are absent from `git diff 8241bd5 HEAD`. That review recorded 305 passed (`test_issue17_item4_decisions.py`, `test_issue33_budget.py`, `test_issue45_restore_unit.py`) and 5 passed (`tests/test_issue45_restore.py`). After this review, `~/.config/herdr/config.toml` mtime is still `1784456394` and the directory mtime is still `1791382452`.
|
||||
|
||||
## Tests this round
|
||||
|
||||
`--basetemp=/tmp/518efollow.hbS1ua`. `tests/test_issue17_item4_decisions.py`, `tests/test_issue33_budget.py`, `tests/test_issue5_phase1_delegation.py`, `tests/test_tier1_unit.py`: **442 passed** in 472.25 s. Snapshot porcelain stayed empty. Main stayed `56d0bfd`.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Review: Issue #17 follow-ups @ 8241bd5
|
||||
|
||||
- Role: Reviewer (grok)
|
||||
- Identity: loop `loop-20261007T224638Z-cf1efb`, base `56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3` (main), head `8241bd53b179cf8332541264c3bf12b8e3eca44a`, tree `efef640d36f6666f107e188d554c39f05400d4ac`, six commits, clean snapshot
|
||||
- Scope: `git diff 56d0bfd HEAD`. Seven files, +192/−22, `git diff --check` clean. No version bump and no `VERSIONS.md` edit. Main stayed `56d0bfd`.
|
||||
- Verified read-only at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T224638Z-cf1efb/1`. Python 3.9.6 from the follow-up worktree venv, bash 3.2.57, herdr 0.9.3. Inherited `HERDR_*`, `MAM_*`, and `MQTT_*` were unset. `HOME` and `WORKSPACE_ROOT` were the basetemp. `.mam.env` was not exported.
|
||||
|
||||
## 1. herdr 0.9.3 restored cwd
|
||||
|
||||
`test_b_only_the_resumed_sessions_restored_shell_is_closed` accepts Y's cwd as either `rig.root` or `rig.root/wsY` (`tests/test_issue45_restore.py:275-277`). The comment names 0.9.1 (`$HOME`) and 0.9.3 (the old path). The close still does not read `cwd`.
|
||||
|
||||
`herdr_restore.py:4-20` says the same thing. `SKILL.md` describes the close as "same session id, no agent, nothing running" and does not claim the missing directory becomes `$HOME`. The historical `VERSIONS.md` line is outside this change, as the task asked.
|
||||
|
||||
On 0.9.3 a removed directory comes back as a placeholder: `restore_error` is set and `pane process-info` fails with `pane_not_found`. `_nothing_running` (`herdr_restore.py:58-65`) closes that pane. Any other `CalledProcessError` still propagates, so the run stops and the pane is not closed. A process-info that succeeds still uses `_shell_in_foreground` (`:50-55`): a real shell pid in the foreground, and nothing else. The unit tests cover the placeholder, `pane_not_found` without `restore_error`, and a different error (`tests/test_issue45_restore_unit.py:447-462`).
|
||||
|
||||
On this Mac the whole `tests/test_issue45_restore.py` file passed, including Y being closed and X's normal empty shell still being the one that closes, while the user's shell, the live agent, and the busy shell stay. The fixture's HOME is the throwaway rig (`test_issue45_restore.py:5-6`, `:179`). `~/.config/herdr/config.toml` mtime stayed `1784456394` and the directory mtime stayed `1791382452`.
|
||||
|
||||
## 2. A failed decision exits 1
|
||||
|
||||
`_decided` (`multi-agent-mux-delegate-job:112-118`) keeps the printed answer only when the command exits 0 and the answer is non-empty. Otherwise `_decision_failed` (`:106-109`) prints the subcommand and `exit 1`. `_exit_for` (`:122-126`) also requires a decimal answer before `exit`. `cmd_wait` uses the same two checks (`:1070-1071`). `finalize-end` keeps its fd-3 swap and treats a non-zero status as the same failure (`:852-857`). An empty answer with status 0 is still accepted there, which is the path that leaves the job unclosed when the wrapper is already gone.
|
||||
|
||||
`printf -v` and `[[ =~ ^[0-9]+$ ]]` are valid on this bash 3.2.57. The assignment is not inside `local`, so a failing command substitution is visible to the `||` branch.
|
||||
|
||||
Sourced from base `56d0bfd`, the same human submit with a broken `exit-code` ended at the old line 414: `exit: : numeric argument required`, rc **255**, after a normal wall-clock close. The new `_exit_for` on that failure, on an empty answer, and on `wait-rc` rc 127 each exits **1** with `could not decide`.
|
||||
|
||||
`subscriber-up` stays in `if _decide subscriber-up` (`:358`, `:530`). A non-zero result is "not up", and the following `_decided sub-status` exits 1 when the module itself is broken. `stall-watch` stays `_decide stall-watch … || return 0` (`:692` area, unchanged). A failed watch decision skips the watcher and the job continues; the watcher never closes a job, and turning that skip into `exit 1` would fail a job that 7209750 and 56d0bfd would still run. The static guard `test_s_no_printed_decision_is_read_raw` allows only the `_decided` capture and the `finalize-end` capture, and forbids `exit "$(`.
|
||||
|
||||
Successful decisions are unchanged: the old-bash comparison in `tests/test_issue17_item4_decisions.py` passed, and `test_f_the_normal_path_is_unchanged` gets rc 3, the audit-log path on the last stdout line, and no `could not decide`.
|
||||
|
||||
## 3. "Stdlib only"
|
||||
|
||||
`wrapper.py:24-31` now says this module imports the standard library, and `python -m lib_py.delegation.wrapper` imports the package first. `lib_py/delegation/__init__.py:19-20` imports `service`, and `service.py` imports `mqtt_common`, which imports `paho.mqtt.client`. The wrapper's `$PY` already needed paho for the subscriber and `publish_event.py`. The command line is still `_decide` at `multi-agent-mux-delegate-job:99-101`.
|
||||
|
||||
## 4. `python3` on PATH
|
||||
|
||||
`lib_int.sh:9-10` records that `mam_int` and `mam_int_knob` run `python3` from PATH and, without one, print nothing and return 127. The function bodies are still the two `python3 -I` calls (`:17-18`). No caller was switched to `$PY`.
|
||||
|
||||
## Tests
|
||||
|
||||
`--basetemp=/tmp/a652follow.8mWeKg`. `tests/test_issue17_item4_decisions.py`, `tests/test_issue33_budget.py`, `tests/test_issue45_restore_unit.py`: **305 passed** in 360.13 s.
|
||||
|
||||
`--basetemp=/tmp/a652herdr.CltSYm`. `tests/test_issue45_restore.py`: **5 passed** in 65.96 s. Snapshot `git status` was empty after both.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -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.6"
|
||||
MAM_VERSION="4.8.7"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#
|
||||
# Issue #17 item 4: the rule itself is lib_py/ints.py, which the loop runner
|
||||
# imports too (runner.py::_positive_int); these functions only call it.
|
||||
# They run `python3` from PATH (this file has no $PY when sourced alone):
|
||||
# without one, mam_int and mam_int_knob print nothing (rc 127).
|
||||
_MAM_INTS_PY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib_py/ints.py"
|
||||
|
||||
# mam_int <value> <min>
|
||||
|
||||
@@ -5,12 +5,16 @@ injects; everything it used to decide in bash is here, and it branches only on
|
||||
what a subcommand prints or returns:
|
||||
|
||||
sub-status <rc> --job J --registry-dir R subscriber rc -> job status (was _map_sub_rc)
|
||||
subscriber-up <rc> exit 0: a subscriber that ended before
|
||||
subscriber-up <rc> [--print] exit 0: a subscriber that ended before
|
||||
SUBSCRIBED still lets the job proceed
|
||||
exit-code <status> the wrapper's exit code (was _rc_for, with
|
||||
MAM_DELEGATE_FAIL_CLOSED=0 -> 0)
|
||||
wait-rc <rc> `wait`'s rc (either clock is a timeout, 2)
|
||||
stall-watch <session> <agent> exit 0: start the stall watcher (Issue #30)
|
||||
stall-watch <session> <agent> [--print] exit 0: start the stall watcher (Issue #30)
|
||||
|
||||
The two yes/no decisions answer by exit code (0 yes, 1 no); with --print they
|
||||
print `yes`/`no` and exit 0, so a failure to decide (any other exit code, or
|
||||
no answer) cannot pass for "no". The wrapper uses --print.
|
||||
finalize-begin / finalize-end ... finalize_unfinished, split around the one
|
||||
herdr diagnostic probe bash still runs
|
||||
|
||||
@@ -21,8 +25,12 @@ checks that the wrapper is still alive throughout both waits and before each
|
||||
side effect, and leaves without closing or publishing once it is gone - as the
|
||||
same loops in bash ended with the wrapper.
|
||||
|
||||
Stdlib only and Python 3.9 (macOS /usr/bin/python3); registry.py and
|
||||
publish_event.py run as the processes the wrapper ran, with its interpreter.
|
||||
This module imports only the standard library and runs on Python 3.9 (macOS
|
||||
/usr/bin/python3). The wrapper runs it as `python -m lib_py.delegation.wrapper`,
|
||||
which imports the package first: lib_py/delegation/__init__.py loads the
|
||||
service and with it paho-mqtt, so the wrapper's $PY needs paho - as it always
|
||||
did for the subscriber and publish_event.py. registry.py and publish_event.py
|
||||
run as the processes the wrapper ran, with its interpreter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -290,12 +298,15 @@ def _parser() -> argparse.ArgumentParser:
|
||||
s.add_argument("rc", type=int)
|
||||
s.add_argument("--job", required=True)
|
||||
s.add_argument("--registry-dir", required=True)
|
||||
sub.add_parser("subscriber-up").add_argument("rc", type=int)
|
||||
s = sub.add_parser("subscriber-up")
|
||||
s.add_argument("rc", type=int)
|
||||
s.add_argument("--print", action="store_true", help="print yes/no and exit 0")
|
||||
sub.add_parser("exit-code").add_argument("status")
|
||||
sub.add_parser("wait-rc").add_argument("rc", type=int)
|
||||
s = sub.add_parser("stall-watch")
|
||||
s.add_argument("session")
|
||||
s.add_argument("agent")
|
||||
s.add_argument("--print", action="store_true", help="print yes/no and exit 0")
|
||||
for name in ("finalize-begin", "finalize-end"):
|
||||
s = sub.add_parser(name)
|
||||
s.add_argument("--job", required=True)
|
||||
@@ -321,18 +332,25 @@ def _parser() -> argparse.ArgumentParser:
|
||||
return p
|
||||
|
||||
|
||||
def _yes_no(answer: bool, printed: bool) -> int:
|
||||
if printed:
|
||||
print("yes" if answer else "no")
|
||||
return 0
|
||||
return 0 if answer else 1
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
a = _parser().parse_args(argv)
|
||||
if a.cmd == "sub-status":
|
||||
print(sub_status(a.rc, a.registry_dir, a.job))
|
||||
elif a.cmd == "subscriber-up":
|
||||
return 0 if subscriber_up(a.rc) else 1
|
||||
return _yes_no(subscriber_up(a.rc), a.print)
|
||||
elif a.cmd == "exit-code":
|
||||
print(exit_code(a.status))
|
||||
elif a.cmd == "wait-rc":
|
||||
print(wait_rc(a.rc))
|
||||
elif a.cmd == "stall-watch":
|
||||
return 0 if stall_watch(a.session, a.agent) else 1
|
||||
return _yes_no(stall_watch(a.session, a.agent), a.print)
|
||||
elif a.cmd == "finalize-begin":
|
||||
print(finalize_begin(a))
|
||||
else:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Issue #45 (B): close the restored empty shells of the session being resumed.
|
||||
|
||||
With restore-on-restart off (lib_py/herdr_config.py) a restarted MAM server
|
||||
still brings every saved pane back - as an empty shell in its old cwd, or in
|
||||
$HOME when that directory is gone. A pane keeps the `agent_session` its agent
|
||||
still brings every saved pane back - as an empty shell in its old cwd. When
|
||||
that directory is gone, herdr 0.9.1 opens the shell in $HOME and herdr 0.9.3
|
||||
keeps the old path with no shell in it (see 4.); nothing below looks at the
|
||||
cwd. A pane keeps the
|
||||
`agent_session` its agent
|
||||
reported before the server died. resume_session.sh calls this once, right
|
||||
before it spawns, with the id it is about to resume; a pane is closed only when
|
||||
all of these hold:
|
||||
@@ -11,7 +14,10 @@ all of these hold:
|
||||
2. herdr sees no live agent in it;
|
||||
3. its agent_session value is that id - so a user's pane, another session's
|
||||
pane and a freshly split pane (no agent_session yet) are never candidates;
|
||||
4. its foreground process is the shell itself (nothing running in it).
|
||||
4. its foreground process is the shell itself (nothing running in it) - or
|
||||
it has no process at all: herdr 0.9.3 restores a pane whose directory is
|
||||
gone as a placeholder (`restore_error` set) that `pane process-info`
|
||||
reports as pane_not_found.
|
||||
|
||||
The registry is not consulted: a session whose server died stays `running`
|
||||
there, so "running" cannot tell a live pane from a restored one.
|
||||
@@ -49,6 +55,16 @@ def _shell_in_foreground(herdr: str, session: str, pane_id: str) -> bool:
|
||||
return _is_pid(shell) and _is_pid(fg) and fg == shell
|
||||
|
||||
|
||||
def _nothing_running(herdr: str, session: str, pane: dict) -> bool:
|
||||
try:
|
||||
return _shell_in_foreground(herdr, session, pane["pane_id"])
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Only herdr's own placeholder counts: any other failure still stops the run.
|
||||
if pane.get("restore_error") and "pane_not_found" in (e.stderr or "") + (e.stdout or ""):
|
||||
return True
|
||||
raise
|
||||
|
||||
|
||||
def _is_pid(v) -> bool:
|
||||
return isinstance(v, int) and not isinstance(v, bool) and v > 0
|
||||
|
||||
@@ -66,7 +82,7 @@ def close_restored_shells(session_id: str, herdr: str = "herdr",
|
||||
continue
|
||||
if (p.get("agent_session") or {}).get("value") != session_id:
|
||||
continue
|
||||
if not _shell_in_foreground(herdr, session, pane_id):
|
||||
if not _nothing_running(herdr, session, p):
|
||||
continue
|
||||
_herdr(herdr, session, "pane", "close", pane_id)
|
||||
print(f"closed restored pane {pane_id} (cwd={p.get('cwd', '')}, agent_session={session_id})",
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
from typing import List, Sequence
|
||||
|
||||
# Framework version locked with framework constants
|
||||
FRAMEWORK_VERSION = "4.8.6"
|
||||
FRAMEWORK_VERSION = "4.8.7"
|
||||
|
||||
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.6
|
||||
version: 4.8.7
|
||||
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.6
|
||||
version: 4.8.7
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
|
||||
@@ -100,6 +100,44 @@ _decide() {
|
||||
PYTHONPATH="$SCRIPT_DIR/..${PYTHONPATH:+:$PYTHONPATH}" "$PY" -m lib_py.delegation.wrapper "$@"
|
||||
}
|
||||
|
||||
# A decision wrapper.py could not make (its module or $PY broken) ends the
|
||||
# wrapper with 1 (fail-closed): never with its own code, 0, or the empty answer
|
||||
# (`exit ""` is 255 on bash 3.2).
|
||||
_decision_failed() {
|
||||
echo "ERROR: the wrapper could not decide '$1' (lib_py.delegation.wrapper: $2); exiting 1 (fail-closed)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# _decided <var> <subcommand> <args...>: a printed decision into $var.
|
||||
_decided() {
|
||||
local __var="$1" __out __rc=0
|
||||
shift
|
||||
__out="$(_decide "$@")" || __rc=$?
|
||||
[ "$__rc" -eq 0 ] || _decision_failed "$1" "rc $__rc"
|
||||
[ -n "$__out" ] || _decision_failed "$1" "no answer"
|
||||
printf -v "$__var" '%s' "$__out"
|
||||
}
|
||||
|
||||
# _decided_yes <subcommand> <args...>: a yes/no decision (asked with --print,
|
||||
# so "no" is an answer and a failed call is not): returns 0 for yes, 1 for no.
|
||||
_decided_yes() {
|
||||
local yn
|
||||
_decided yn "$@" --print
|
||||
case "$yn" in
|
||||
yes) return 0 ;;
|
||||
no) return 1 ;;
|
||||
esac
|
||||
_decision_failed "$1" "answer '$yn'"
|
||||
}
|
||||
|
||||
# _exit_for <status>: exit with the wrapper exit code for a job status.
|
||||
_exit_for() {
|
||||
local code
|
||||
_decided code exit-code "$1"
|
||||
[[ "$code" =~ ^[0-9]+$ ]] || _decision_failed exit-code "answer '$code'"
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
# _pub_cmd <extra...>: the publish command as one shell-quoted string.
|
||||
_pub_cmd() {
|
||||
local s
|
||||
@@ -355,18 +393,18 @@ EOF
|
||||
else
|
||||
local sub_exit=0
|
||||
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
||||
if _decide subscriber-up "$sub_exit"; then
|
||||
if _decided_yes subscriber-up "$sub_exit"; then
|
||||
sub_ready=1
|
||||
break
|
||||
else
|
||||
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||
local mapped_st
|
||||
mapped_st="$(_decide sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||
_decided mapped_st sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
|
||||
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
||||
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
exit "$(_decide exit-code "$mapped_st")"
|
||||
_exit_for "$mapped_st"
|
||||
fi
|
||||
fi
|
||||
sleep 0.2
|
||||
@@ -402,7 +440,7 @@ EOF
|
||||
echo "subscriber output:"; cat "$logf" || true
|
||||
|
||||
local job_status
|
||||
job_status="$(_decide sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||
_decided job_status sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
|
||||
|
||||
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
|
||||
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
||||
@@ -411,7 +449,7 @@ EOF
|
||||
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
exit "$(_decide exit-code "$job_status")"
|
||||
_exit_for "$job_status"
|
||||
else
|
||||
# Implement loop/discuss orchestrator
|
||||
local iteration=1
|
||||
@@ -527,18 +565,18 @@ EOF
|
||||
else
|
||||
local sub_exit=0
|
||||
wait "$sub_pid" 2>/dev/null || sub_exit=$?
|
||||
if _decide subscriber-up "$sub_exit"; then
|
||||
if _decided_yes subscriber-up "$sub_exit"; then
|
||||
sub_ready=1
|
||||
break
|
||||
else
|
||||
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||
local mapped_st
|
||||
mapped_st="$(_decide sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||
_decided mapped_st sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
|
||||
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
|
||||
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
exit "$(_decide exit-code "$mapped_st")"
|
||||
_exit_for "$mapped_st"
|
||||
fi
|
||||
fi
|
||||
sleep 0.2
|
||||
@@ -571,7 +609,7 @@ EOF
|
||||
echo "subscriber output:"; cat "$logf" || true
|
||||
|
||||
local job_status
|
||||
job_status="$(_decide sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR")"
|
||||
_decided job_status sub-status "$sub_rc" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
|
||||
|
||||
echo "Job role $display_role finished with status: $job_status"
|
||||
|
||||
@@ -590,7 +628,7 @@ EOF
|
||||
else
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
exit "$(_decide exit-code "$job_status")"
|
||||
_exit_for "$job_status"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -615,7 +653,7 @@ EOF
|
||||
else
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
exit "$(_decide exit-code "$job_status")"
|
||||
_exit_for "$job_status"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -689,7 +727,7 @@ reap_subscriber() {
|
||||
start_stall_watch() {
|
||||
local session="$1" agent="$2" tok="${3:-}" att="${4:-}"
|
||||
STALL_PID=""
|
||||
_decide stall-watch "$session" "$agent" || return 0
|
||||
_decided_yes stall-watch "$session" "$agent" || return 0
|
||||
local args=(--job "$JOB_ID" --registry-dir "$REGISTRY_DIR" --target "${session#herdr:}"
|
||||
--agent "$agent" --herdr-session "${HERDR_SESSION_NAME:-}" --watch-pid "$$")
|
||||
[ -n "$tok" ] && args+=(--expect-dispatch-token "$tok")
|
||||
@@ -805,10 +843,11 @@ _within_deadline() {
|
||||
finalize_unfinished() {
|
||||
local job_id="$1" job_status="$2" sub_rc="$3" phase="${4:-}"
|
||||
local target_sess="${5:-${current_session:-${target_session:-$AGENT_SESSION}}}"
|
||||
local step final evidence_until deadline out
|
||||
read -r step final evidence_until deadline <<<"$(_decide finalize-begin --job "$job_id" \
|
||||
local step final evidence_until deadline out begin end_rc=0
|
||||
_decided begin finalize-begin --job "$job_id" \
|
||||
--registry-dir "$REGISTRY_DIR" --status "$job_status" ${START_TIME:+--start-time "$START_TIME"} \
|
||||
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" --agent "$AGENT" --session "$target_sess")"
|
||||
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" --agent "$AGENT" --session "$target_sess"
|
||||
read -r step final evidence_until deadline <<<"$begin"
|
||||
case "$step" in
|
||||
keep) return 0 ;;
|
||||
done) FINAL_JOB_STATUS="$final"; return 0 ;;
|
||||
@@ -826,7 +865,8 @@ finalize_unfinished() {
|
||||
${START_TIME:+--start-time "$START_TIME"} --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||
--sub-rc "$sub_rc" --phase "$phase" --type "$TYPE" --final "$final" --evidence-until "$evidence_until" \
|
||||
--dispatch-token "${_disp_tok:-}" --preflight-reason "${PREFLIGHT_REASON:-}" \
|
||||
--python "$PY" --scripts-dir "$SCRIPT_DIR/scripts" --watch-pid "$$" 3>&1 1>&4 4>&-)"; } 4>&1
|
||||
--python "$PY" --scripts-dir "$SCRIPT_DIR/scripts" --watch-pid "$$" 3>&1 1>&4 4>&-)" || end_rc=$?; } 4>&1
|
||||
[ "$end_rc" -eq 0 ] || _decision_failed finalize-end "rc $end_rc"
|
||||
FINAL_JOB_STATUS="$out"
|
||||
}
|
||||
|
||||
@@ -1039,7 +1079,8 @@ cmd_wait() {
|
||||
fi
|
||||
# Issue #33: either clock is a timeout (rc 2) to callers of `wait`, as before;
|
||||
# the subscriber's own log says which clock it was.
|
||||
rc="$(_decide wait-rc "$rc")"
|
||||
_decided rc wait-rc "$rc"
|
||||
[[ "$rc" =~ ^[0-9]+$ ]] || _decision_failed wait-rc "answer '$rc'"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
|
||||
@@ -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.6
|
||||
version: 4.8.7
|
||||
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.6
|
||||
version: 4.8.7
|
||||
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.6
|
||||
version: 4.8.7
|
||||
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.6
|
||||
version: 4.8.7
|
||||
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.6
|
||||
version: 4.8.7
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -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.6
|
||||
version: 4.8.7
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Messaging System Technical Analysis & Architecture Report
|
||||
|
||||
Applies to: MAM v4.8.6
|
||||
Applies to: MAM v4.8.7
|
||||
|
||||
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+29
-10
@@ -6,7 +6,7 @@
|
||||
|
||||
## 📌 현재 버전 개요 (Current Release)
|
||||
|
||||
- **프레임워크 버전**: `v4.8.6`
|
||||
- **프레임워크 버전**: `v4.8.7`
|
||||
- **최신 릴리스 일시**: 2026-10-08 (KST)
|
||||
- **기준 브랜치**: `main`
|
||||
- **핵심 아키텍처**:
|
||||
@@ -24,23 +24,42 @@
|
||||
|
||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.8.6`로 동기화되어 배포됩니다.
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.8.7`로 동기화되어 배포됩니다.
|
||||
|
||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||
| :--- | :---: | :--- | :---: |
|
||||
| **`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 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-create`** | `4.8.7` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.8.7` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.8.7` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.8.7` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.8.7` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.8.7` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.8.7` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.8.7` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||
|
||||
### 🚀 `v4.8.7` — Issue #17 후속: 래퍼가 판정을 못 내리면 1 로 끝나고, resume 이 herdr 0.9.3 의 자리표시 pane 을 닫는다 (2026-10-08)
|
||||
|
||||
> **패치 (PATCH Release)**: v4.8.6(PR #51) 리뷰가 남긴 후속. 판정 모듈이 실패할 때의 래퍼 종료 코드, herdr 0.9.3 에서 드러난 #45 B 의 빈틈, 문서 두 곳. 정상 경로의 종료 코드·출력·레지스트리·이벤트는 같다.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **판정 실패는 1 (`multi-agent-mux-delegate-job`, `lib_py/delegation/wrapper.py`)**: `exit "$(_decide exit-code …)"` 는 판정 모듈이나 `$PY` 가 깨지면 `exit ""` → bash 3.2 에서 255 였고, 다른 판정도 저마다 다르게 실패했다(wait-rc 255, finalize-begin 실패 시 finalize-end 의 argparse 2, set -e 아래 인터프리터 자신의 코드 1·127). 이제 값을 내는 판정은 `_decided`(0 이 아닌 rc·빈 답이면 실패), 종료는 `_exit_for`(숫자가 아니면 실패)를 거치고, 실패는 `_decision_failed` 가 `ERROR:` 한 줄과 함께 1 로 끝낸다(fail-closed).
|
||||
2. **예/아니오 판정 (리뷰 codex R1)**: `subscriber-up`·`stall-watch` 는 rc 로만 답해, 실행조차 못 한 호출(import 오류 rc 1, 인터프리터 없음 127)이 "아니오"로 읽혔다 — 0 으로 끝난 구독자에서 제출이 0(completed)으로 끝났고, stall-watch 실패는 감시를 조용히 껐다. 두 서브커맨드는 `--print` 로 `yes`/`no` 를 출력하고(rc 0), 래퍼는 `_decided_yes` 로 묻는다. 그 밖의 답·실패는 1 로 끝난다. `--print` 없는 rc 계약(예 0, 아니오 1)은 그대로.
|
||||
3. **herdr 0.9.3 의 자리표시 pane (`lib_py/herdr_restore.py`)**: herdr 0.9.3 은 저장된 디렉터리가 사라진 pane 을 `$HOME` 의 셸이 아니라 원래 cwd 의 자리표시(`restore_error` "Saved directory is unavailable…", 프로세스 없음)로 복원하고, 그 pane 의 `pane process-info` 는 `pane_not_found` 로 답한다. `close_restored_shells` 는 여기서 예외로 exit 1 하며 다른 pane 을 보지 않아, #45 B 가 닫아야 할 지워진 워크트리의 pane 이 남았다. `restore_error` 가 있고 **그리고** `pane_not_found` 인 경우만 "실행 중인 것 없음"으로 보고 닫는다. 다른 process-info 실패는 전처럼 exit 1. docstring 도 버전별 복원 위치로 정정.
|
||||
4. **테스트 전제 (`tests/test_issue45_restore.py`)**: 지워진 cwd 의 복원 위치를 herdr 0.9.1(`$HOME`)·0.9.3(원래 경로) 둘 다 받는다.
|
||||
5. **문서**: `wrapper.py` 머리말 — 모듈은 stdlib 만 import 하지만 `python -m lib_py.delegation.wrapper` 는 패키지 `__init__` 을 거쳐 paho 를 싣는다(N1). `lib_int.sh` — 단독 source 될 때 PATH 의 `python3` 를 쓴다(N3).
|
||||
|
||||
#### ✅ 품질 검증
|
||||
- **새 테스트**: 판정 실패(traceback·rc 127·빈 답) × 판정(exit-code, sub-status, finalize-begin·-end, wait-rc, subscriber-up 직접·loop/discuss, stall-watch)이 1 과 `ERROR:` 줄로 끝남 — 수정 전 코드에서 255 또는 0 으로 실패. 예/아니오 두 분기 보존, `--print` 단위표, rc 로 읽는 예/아니오 판정을 금지하는 정적 가드. herdr stub 으로 자리표시 닫기(56d0bfd 에서 실패)와, `restore_error` 없는 `pane_not_found`·다른 오류의 `restore_error` 는 여전히 예외.
|
||||
- **리뷰 (병합 워크스페이스, 루프 `--all-reviewer`)**: 1회차(@8241bd5) claude·grok PASS, agy NOT PASS(래퍼 문자열을 고정한 기존 정적 테스트 2 건이 `_decided`/`_exit_for` 로 바뀐 호출을 못 따라감), codex NOT PASS(R1 위 2). 541c3fe·65b78b6 에서 수정. 2회차(@65b78b6) **4/4 PASS**. 리포트와 2회차 identity 사이드카는 `.agents/reports/<session>/`.
|
||||
- **전체 스위트 (병합 워크스페이스, macOS·Python 3.9·herdr 0.9.3, MQTT 환경변수 없이)**: 65b78b6 **1730 passed / 0 failed / 56 skipped**(72 분). skip 중 18 건은 새 워크트리에 nats-docker 서브모듈이 없어서였고, 서브모듈을 받은 뒤 `tests/test_deploy_freshness.py` **31 passed**. 나머지 38 skip 은 v4.8.6 과 같다(tomllib 전용 34 등). v4.8.6 의 herdr 0.9.3 실패(`test_issue45_restore`)는 이번에 통과.
|
||||
|
||||
#### 📝 후속
|
||||
- `tests/test_issue17_item4_decisions.py` 의 `_env` 가 상속된 `WORKSPACE_ROOT` 를 비우지 않아, 옛/새 bash 비교 헬퍼가 에이전트 pane 의 워크스페이스로 lib.sh 를 초기화할 수 있다(claude 비차단).
|
||||
|
||||
### 🚀 `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 의 나머지 항목은 열린 채 남는다.
|
||||
|
||||
@@ -628,3 +628,116 @@ def test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back(monkeypatch, second
|
||||
monkeypatch.setattr(decide, "time", clock)
|
||||
decide._sleep(argparse.Namespace(watch_pid=None), seconds)
|
||||
assert seconds <= clock.slept <= seconds + 0.5, clock.slept
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F - a decision wrapper.py cannot make ends the wrapper with 1 (N2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _failing_py(tmp_path: Path, decision: str, mode: str) -> Path:
|
||||
"""The wrapper's $PY: the real interpreter, except that `decision` fails -
|
||||
with a traceback and rc 1 (a broken module), with rc 127 (an interpreter
|
||||
that cannot run), or with rc 0 and no answer.
|
||||
job_subscriber.py is a stand-in exiting $SUB_RC when SUB_STANDIN=1."""
|
||||
shim = tmp_path / "py-fail"
|
||||
fail = {"error": 'echo "ModuleNotFoundError: No module named lib_py.delegation.wrapper" >&2; exit 1',
|
||||
"gone": 'echo "py: not found" >&2; exit 127', "empty": "exit 0"}[mode]
|
||||
shim.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'if [ "${SUB_STANDIN:-0}" = 1 ]; then case "$1" in */scripts/job_subscriber.py) exit "${SUB_RC:-0}";; esac; fi\n'
|
||||
f'case "$*" in *"lib_py.delegation.wrapper {decision} "*|*"lib_py.delegation.wrapper {decision}") {fail};; esac\n'
|
||||
f'exec "{sys.executable}" "$@"\n')
|
||||
shim.chmod(0o755)
|
||||
return shim
|
||||
|
||||
|
||||
def _human_submit(tmp_path: Path, py: str, typ: str = "direct", **extra) -> subprocess.CompletedProcess:
|
||||
"""The real wrapper, one human job, an unreachable broker: the subscriber
|
||||
ends with rc 3, the job is closed at its 3 s wall clock and the wrapper
|
||||
exits through sub-status, finalize-begin/-end and exit-code (rc 3)."""
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("HERDR_", "MAM_", "SKS_", "MQTT_")) and k not in ("PYTHONPATH", "WORKSPACE_ROOT")}
|
||||
env.update(HOME=str(tmp_path), WORKSPACE_ROOT=str(tmp_path), DELEGATE_JOB_PYTHON=py,
|
||||
AGENT_PYTHON_BIN=sys.executable, PYTHONPATH=str(SKILLS), DELEGATE_JOB_LOGS_DIR=str(tmp_path / "logs"),
|
||||
MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"), MQTT_BROKER="127.0.0.1", MQTT_PORT="9", MQTT_TLS="0")
|
||||
env.update({k: str(v) for k, v in extra.items()})
|
||||
loop = ["--reviewer", "human", "--reviewer-session", "herdr:human"] if typ != "direct" else []
|
||||
return subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--type", typ, *loop,
|
||||
"--registry-dir", str(tmp_path / "reg"),
|
||||
"--workdir", str(tmp_path), "--prompt", "n2", "--timeout", "3", "--idle-timeout", "2"],
|
||||
capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=120)
|
||||
|
||||
|
||||
def test_f_the_normal_path_is_unchanged(tmp_path):
|
||||
res = _human_submit(tmp_path, sys.executable)
|
||||
assert res.returncode == 3, res.stdout[-800:] + res.stderr[-800:]
|
||||
assert res.stdout.strip().splitlines()[-1] == str(tmp_path / "logs" / next((tmp_path / "reg").glob("*.json")).stem)
|
||||
assert "could not decide" not in res.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision,mode", [
|
||||
("exit-code", "error"), ("exit-code", "empty"),
|
||||
("sub-status", "error"), ("sub-status", "gone"), ("sub-status", "empty"),
|
||||
("finalize-begin", "error"), ("finalize-begin", "empty"),
|
||||
("finalize-end", "error"), ("finalize-end", "gone"), # it may answer nothing (its wrapper gone): rc only
|
||||
])
|
||||
def test_f_a_failed_decision_ends_the_submit_with_1(tmp_path, decision, mode):
|
||||
res = _human_submit(tmp_path, str(_failing_py(tmp_path, decision, mode)))
|
||||
assert res.returncode == 1, (res.returncode, res.stderr[-800:])
|
||||
assert f"ERROR: the wrapper could not decide '{decision}'" in res.stderr, res.stderr[-800:]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["error", "gone", "empty"])
|
||||
@pytest.mark.parametrize("decision,typ", [("subscriber-up", "direct"), ("subscriber-up", "loop"),
|
||||
("stall-watch", "direct")])
|
||||
def test_f_a_failed_yes_no_decision_ends_the_submit_with_1(tmp_path, decision, typ, mode):
|
||||
"""Review R1 (codex): a yes/no decision that fails must not pass for "no".
|
||||
The subscriber stand-in ends with 0 before SUBSCRIBED: read as "not up",
|
||||
the error path mapped rc 0 to completed and the submit ended with 0."""
|
||||
res = _human_submit(tmp_path, str(_failing_py(tmp_path, decision, mode)), typ, SUB_STANDIN=1, SUB_RC=0)
|
||||
assert res.returncode == 1, (res.returncode, res.stderr[-800:])
|
||||
assert f"ERROR: the wrapper could not decide '{decision}'" in res.stderr, res.stderr[-800:]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("typ", ["direct", "loop"])
|
||||
@pytest.mark.parametrize("sub_rc,up", [(0, True), (1, False)])
|
||||
def test_f_yes_and_no_keep_their_branches(tmp_path, typ, sub_rc, up):
|
||||
"""The same runs without a failure: "yes" goes on, "no" takes the error path."""
|
||||
res = _human_submit(tmp_path, str(_failing_py(tmp_path, "none", "error")), typ, SUB_STANDIN=1, SUB_RC=sub_rc)
|
||||
assert "could not decide" not in res.stderr, res.stderr[-800:]
|
||||
assert ("subscriber died early" in res.stderr) is (not up), res.stderr[-800:]
|
||||
if typ == "direct":
|
||||
assert res.returncode == (0 if up else 1), (res.returncode, res.stderr[-800:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rc,up", [(0, True), (3, True), (1, False), (2, False), (4, False)])
|
||||
def test_u_yes_no_print(capsys, rc, up):
|
||||
assert decide.main(["subscriber-up", str(rc), "--print"]) == 0
|
||||
assert capsys.readouterr().out == ("yes\n" if up else "no\n")
|
||||
assert decide.main(["subscriber-up", str(rc)]) == (0 if up else 1) # the rc form is unchanged
|
||||
assert decide.main(["stall-watch", "herdr:c", "claude", "--print"]) == 0
|
||||
assert capsys.readouterr().out == "yes\n"
|
||||
assert decide.main(["stall-watch", "remote:r", "claude", "--print"]) == 0
|
||||
assert capsys.readouterr().out == "no\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["error", "empty"])
|
||||
def test_f_a_failed_wait_rc_ends_wait_with_1(tmp_path, mode):
|
||||
py = _failing_py(tmp_path, "wait-rc", mode)
|
||||
script = f'source "{WRAPPER}"; PY="{py}"; set +e; (cmd_wait --job j1 --timeout 5); echo "rc=$?"'
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, cwd=str(tmp_path), timeout=60,
|
||||
env=_env(tmp_path, SUB_STANDIN=1, SUB_RC=4, DELEGATE_JOB_PYTHON=py, WORKSPACE_ROOT=tmp_path))
|
||||
assert "rc=1" in res.stdout, (res.stdout, res.stderr[-800:])
|
||||
assert "ERROR: the wrapper could not decide 'wait-rc'" in res.stderr, res.stderr[-800:]
|
||||
|
||||
|
||||
def test_s_no_printed_decision_is_read_raw():
|
||||
"""Every printed answer goes through _decided/_exit_for/_decided_yes (the
|
||||
yes/no ones with --print), and finalize-end is checked by its rc."""
|
||||
text = "\n".join(l for l in WRAPPER.read_text().splitlines() if not l.lstrip().startswith("#"))
|
||||
raw = [l.strip() for l in text.splitlines() if "$(_decide" in l]
|
||||
assert raw == ['__out="$(_decide "$@")" || __rc=$?',
|
||||
'{ out="$(_decide finalize-end --job "$job_id" --registry-dir "$REGISTRY_DIR" --status "$job_status" \\'], raw
|
||||
assert 'exit "$(' not in text
|
||||
assert not re.search(r"\b_decide (subscriber-up|stall-watch)\b", text), "a yes/no decision read by its rc"
|
||||
assert text.count("_decided_yes subscriber-up") == 2 and text.count("_decided_yes stall-watch") == 1
|
||||
@@ -264,7 +264,7 @@ def test_b_only_the_resumed_sessions_restored_shell_is_closed(rig):
|
||||
rig.run_agent(rig.new_pane(rig.root / f"ws{k}", k), ids[k])
|
||||
rig.new_pane(rig.root / "wsU", "U") # a user's plain shell: no agent_session
|
||||
rig.crash(*ids.values())
|
||||
shutil.rmtree(rig.root / "wsY") # a removed worktree: restored into $HOME
|
||||
shutil.rmtree(rig.root / "wsY") # a removed worktree: still restored (cwd below)
|
||||
|
||||
before = rig.restores()
|
||||
rig.start_via_shim()
|
||||
@@ -272,7 +272,9 @@ def test_b_only_the_resumed_sessions_restored_shell_is_closed(rig):
|
||||
rig.run_agent(_pane_with(rig, ids["Z"])["pane_id"], ids["Z"]) # running again
|
||||
rig.hd("pane", "run", _pane_with(rig, ids["X2"])["pane_id"], "sleep 60") # busy shell
|
||||
time.sleep(1)
|
||||
assert _pane_with(rig, ids["Y"])["cwd"] == str(rig.root)
|
||||
# Y's directory is gone: herdr 0.9.1 restores the pane into $HOME (rig.root),
|
||||
# 0.9.3 keeps the old path. Closing does not look at the cwd either way.
|
||||
assert _pane_with(rig, ids["Y"])["cwd"] in (str(rig.root), str(rig.root / "wsY"))
|
||||
n = len(rig.panes())
|
||||
|
||||
for k in ("Z", "X2"):
|
||||
|
||||
@@ -4,6 +4,7 @@ null, 0, negative, bool, string - must leave the pane alone. herdr is a stub
|
||||
here; the real-server behaviour is tests/test_issue45_restore.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -54,3 +55,44 @@ def test_a_busy_pane_is_left_alone(monkeypatch):
|
||||
calls = _stub(monkeypatch, {"shell_pid": 4242, "foreground_process_group_id": 4300})
|
||||
assert hr.close_restored_shells(SID, session="m45-review-probe") == []
|
||||
assert ("pane", "close", "probe-pane") not in calls
|
||||
|
||||
|
||||
# herdr 0.9.3: a pane whose saved directory is gone is restored as a placeholder
|
||||
# with no process; process-info answers pane_not_found (real-server check:
|
||||
# test_issue45_restore.py test_b_only_the_resumed_sessions_restored_shell_is_closed).
|
||||
PLACEHOLDER_ERR = '{"error":{"code":"pane_not_found","message":"pane not found"},"id":"cli:pane:process_info"}'
|
||||
|
||||
|
||||
def _stub_failing(monkeypatch, restore_error, stderr):
|
||||
calls = []
|
||||
|
||||
def fake(herdr, session, *args):
|
||||
calls.append(args)
|
||||
if args[:2] == ("pane", "list"):
|
||||
pane = {"pane_id": "probe-pane", "agent": None, "agent_session": {"value": SID}}
|
||||
if restore_error:
|
||||
pane["restore_error"] = restore_error
|
||||
return {"panes": [pane]}
|
||||
if args[:2] == ("pane", "process-info"):
|
||||
raise subprocess.CalledProcessError(1, ["herdr", *args], output="", stderr=stderr)
|
||||
return {}
|
||||
monkeypatch.setattr(hr, "_herdr", fake)
|
||||
return calls
|
||||
|
||||
|
||||
def test_a_placeholder_for_a_missing_directory_is_closed(monkeypatch):
|
||||
calls = _stub_failing(monkeypatch, "Saved directory is unavailable. Restore the directory and restart "
|
||||
"this session.", PLACEHOLDER_ERR)
|
||||
assert hr.close_restored_shells(SID, session="m45-review-probe") == ["probe-pane"]
|
||||
assert ("pane", "close", "probe-pane") in calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restore_error,stderr", [
|
||||
(None, PLACEHOLDER_ERR), # pane_not_found without herdr's marker
|
||||
("Saved directory is unavailable.", '{"error":{"code":"server_error"}}'), # another failure
|
||||
], ids=["no-restore-error", "other-error"])
|
||||
def test_any_other_process_info_failure_still_stops_the_run(monkeypatch, restore_error, stderr):
|
||||
calls = _stub_failing(monkeypatch, restore_error, stderr)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
hr.close_restored_shells(SID, session="m45-review-probe")
|
||||
assert ("pane", "close", "probe-pane") not in calls
|
||||
@@ -386,8 +386,8 @@ def test_wrapper_exit_code_mapping_literals_are_frozen(mam_sandbox):
|
||||
wrapper = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
text = wrapper.read_text(encoding="utf-8")
|
||||
# Issue #17 item 4: the mapping moved to lib_py/delegation/wrapper.py; the
|
||||
# wrapper reaches it only through `_decide`.
|
||||
for literal in ('_decide sub-status "$sub_rc"', '_decide exit-code "$job_status"', "--sub-rc \"$sub_rc\""):
|
||||
# wrapper reaches it only through `_decide`, via _decided / _exit_for (N2).
|
||||
for literal in ('_decided job_status sub-status "$sub_rc"', '_exit_for "$job_status"', "--sub-rc \"$sub_rc\""):
|
||||
assert literal in text, literal
|
||||
from lib_py.delegation import wrapper as decide # no scripts/ shim: the wrapper calls it with -m
|
||||
assert decide.EXIT_CODES == {"completed": 0, "error": 1, "timeout": 2, "broker_unavailable": 3, "cancelled": 5}
|
||||
|
||||
@@ -844,7 +844,7 @@ def test_g10_delegate_job_rc3_not_mistaken_for_error(mam_sandbox):
|
||||
"""G-10: multi-agent-mux-delegate-job maps rc=3 to broker_unavailable and checks disk status.
|
||||
Issue #17 item 4: the mapping is lib_py/delegation/wrapper.py's sub_status."""
|
||||
delegate_script = mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
assert '_decide sub-status "$sub_rc"' in delegate_script.read_text()
|
||||
assert '_decided job_status sub-status "$sub_rc"' in delegate_script.read_text()
|
||||
from lib_py.delegation import wrapper
|
||||
reg = mam_sandbox / "g10-reg"
|
||||
reg.mkdir()
|
||||
|
||||
Reference in new issue
Block a user