merge: fix/issue-36 (Issue #36: delegated prompts travel by file, a failed delegation ends the loop with rc 1; v4.7.3)

Reviews: authoring workspace 5 reviewers x 3 rounds PASS; merge workspace round 1 @59be1c8 claude PASS, agy/grok/codex NOT PASS (S9 macOS wording, runner prompt-file write failure, empty --prompt-file) -> fixed in 708262a; round 2 @708262a all four PASS (identity: base 59be1c8 -> head 708262a, evidence review-tree). Full suite @59be1c8: 1139 passed, 2 skipped, 1 failed (S9, fixed); targeted suites @708262a: 125 passed. Reports under .agents/reports/. Closes #36.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit was merged in pull request #37.
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-24 09:10:39 +00:00
commit 7a4a0c2091
36 files changed
+1560 -55

No files matched your search

@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260924T085626Z-b8987e",
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
"reviewer": "planner-reviewer-claude-01",
"job_id": "1a9fbbc4",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-24T09:09:43Z"
}
@@ -0,0 +1,97 @@
# 🔍 Review Report — PR #37 델타 리뷰 2회차 (job `1a9fbbc4`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
- **대상 델타**: `59be1c8..708262a` (1커밋, 3파일 `+63/−7`, `git diff --check` 클린), base `main 1debc7d` (v4.7.2).
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T085626Z-b8987e/1`. **identity 확인**: `HEAD = 708262a5…1e30`, `HEAD^{tree} = d601c2d0…5b96` — 브리프와 일치.
- **1회차 내 판정**: PASS + H1~H5 (job `4ab12ae2`). 이번에는 H1(러너 쓰기 실패)·H3(빈 파일)·S9 이식성의 정정을 본다.
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 러너 실험은 in-process 스텁, 래퍼는 `--dry-run`+`--agent human`+도달 불가 브로커, 되돌린 사본은 scratchpad. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 내가 1회차에서 H1 을 재현했던 바로 그 스크립트를 정정된 러너에 다시 돌려 "실패 제출·부분 파일 없음·래퍼 미실행"을 확인했고, 정상·exec 실패·쓰기 실패 세 경로에서 파일이 **정확히 한 번**만 지워지는 것도 확인했다. 비차단 관찰 1건(I1).
## 1. R1/H1 — 러너의 쓰기 실패 (항목 1)
`runner.py:472-491`: `mkdir`·`NamedTemporaryFile`·`write` 가 하나의 `try` 로 들어가고, `prompt_file` 은 `""` 로 초기화된 뒤 파일이 생기는 즉시(`write` 이전) 이름을 받는다. `except OSError` 는 이름이 있으면 지우고 `CompletedProcess(…, 126, "", "cannot write the prompt file under <mam_dir>: <err> (prompt N bytes)")` 를 돌려준다. exec 용 `try/finally`(`:504-516`)는 그 뒤에 따로 있어 두 블록이 겹치지 않는다.
### 실측 (검증 경로 러너 in-process)
| 경로 | 결과 |
|---|---|
| **쓰기 실패**(1회차 H1 재현 스크립트 그대로: `write` 가 `OSError(28)`) | `delegate_job` 이 **None** 을 반환(예외 없음), 로그 `cannot write the prompt file under …: [Errno 28] No space left on device (prompt 5 bytes)`, `.mam` 잔존 **0**, 래퍼 호출 **0** |
| 정상 | 래퍼 호출 시점에 파일 존재·내용 `'hello'`·권한 **0600**, `os.unlink` 호출 **1회**, 잔존 0 |
| exec 실패(`subprocess.run` 이 `OSError(2)`) | rc 126 경로, `os.unlink` 호출 **1회**, 잔존 0, 반환 None |
- 반환하는 `CompletedProcess` 는 exec 실패와 같은 rc 126 이라 기존 처리(`submit failed (exit 126)` 로그, busy 재시도 없음, id 없음 → `failed_submit_jobs` 미등록·None 반환 → 호출자 `return 1`)에 그대로 합류한다. S4c 가 `submit failed (exit 126)`·`cannot write the prompt file`·`Traceback` 부재·래퍼 미실행·잔존 없음을 한꺼번에 단언한다.
- `NamedTemporaryFile` 생성 자체가 실패하면 `prompt_file` 이 빈 채로 `except` 에 들어가 `unlink` 를 건너뛴다 — 지울 것이 없으니 맞다. `mkdir` 실패도 같은 블록이다.
## 2. H3 — 빈 `--prompt-file` 거부 (항목 2)
`multi-agent-mux-delegate-job:193` 의 `[[ -s "$PROMPT_FILE" ]]` 는 **래퍼 `submit` 의 입력**에만 걸린다. 정당한 빈 프롬프트 사용처를 막는지 실측:
| 경로 | 결과 |
|---|---|
| 래퍼 `submit --prompt-file empty.md` | `submit: --prompt-file <절대경로> is empty`, rc 1 (S1e) |
| 래퍼 `submit --prompt-file newline.md`(개행 1바이트) | 등록됨 — `-s` 는 바이트 크기 기준이라 공백만 있는 파일은 통과한다(I1) |
| 래퍼 `submit --prompt ""` | 종전대로 `submit requires --prompt or --prompt-file` 거부 — 텍스트 형식과 대칭 |
| `registry.py register --prompt-file empty.md`(직접) | 등록됨(rc 0) — 등록부는 그대로 |
| loop 형 회차의 `update --prompt-file "$job_dir/prompt.md"` | `cmd_submit` 을 거치지 않으므로 검사 밖(변경 없음) |
즉 새 검사는 "빈 텍스트를 거부하던" 기존 규칙을 파일 형식으로 확장한 것이고, 등록부·loop 회차 경로는 건드리지 않는다. 막히는 정당한 사용처는 없다.
## 3. R2/N1 — S9 의 macOS 이식성
`sys.platform == "linux"` 일 때만 `File too large` 를 단언하고, rc≠0·`prompt-shloop-*` 잔존 없음·`delegate_job_safe: cannot write the prompt file` 은 모든 플랫폼에서 단언한다. 1회차에서 내가 하네스를 직접 돌려 확인한 macOS 동작(rc 1·잔존 없음·원인 문구 출력)과 정확히 일치하는 계약이다.
## 4. 테스트 (항목 1·3)
### 4-1. 수정 없이는 실패하는가 — 확인함
검증 경로 사본에서 `runner.py` 와 래퍼만 `59be1c8` 판으로 되돌리고 `-k "s4c or s1e or s9 or s4 or s1"` 을 돌렸다:
```
2 failed, 6 passed, 1 skipped in 3.11s
FAILED test_s4c_a_prompt_file_that_cannot_be_written_is_a_failed_submit ← H1
FAILED test_s1e_an_empty_prompt_file_is_refused ← H3
```
S9 는 되돌린 사본에서도 통과한다 — `run_loop.sh` 쪽 수정은 이미 `59be1c8` 에 있었고 이번 델타는 테스트의 단언만 이식 가능하게 바꿨기 때문이다(macOS 에서 통과 = R2 정정 확인). S1/S1c/S1d/S4/S4b 는 양쪽 모두 통과(불변 조건).
### 4-2. 검증 경로에서의 실행
```
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_loop_cli.py
tests/test_issue14_delivery_ack.py tests/test_issue25_plan_reuse.py
→ 127 passed, 1 skipped in 215.94s failed 0 (skip = linux_only S1b)
```
1회차의 유일한 실패였던 S9 가 이 호스트에서 통과하고, 새 파일 24건(23 통과 + Linux 전용 skip 1)과 인접 계약 스위트가 모두 통과한다.
### 4-3. 격리
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` 전후 | **차이 0** |
| `prompt-*` 잔존(실제 `.mam` / 검증 경로 `.mam`) | **0 / 0** |
| 검증 경로 tracked 파일 | 변화 없음(HEAD `708262a`), 본 체크아웃 `main 1debc7d` |
## 5. 병합 차단 여부 (항목 3)
1회차 PASS 를 뒤집을 요소는 없다. 델타는 러너의 `try` 범위 확장(`runner.py:472-491`), 래퍼의 `-s` 한 줄(`:193`), 테스트 3건(S4c·S1e·S9 조건부 단언)뿐이고, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 H2(loop SKILL.md 의 `MAM_DIFF_MAX_*` 의미)·H4(`$PROMPT` 후행 개행)는 이 델타 범위 밖이라 그대로 열려 있다.
| # | 비차단 관찰 | 근거 |
|---|---|---|
| I1 | `-s` 는 바이트 크기 검사라 개행·공백만 있는 프롬프트 파일은 통과한다. 텍스트 형식(`--prompt ""`)의 거부와 완전히 대칭이려면 `[[ -n "$(tr -d '[:space:]' < "$PROMPT_FILE")" ]]` 류가 필요하지만, 실무에서 그런 입력은 없다 | `multi-agent-mux-delegate-job:193`, 실측 |
## 6. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 708262a… / d601c2d…
# H1 재현 스크립트(1회차 §1 의 것)를 정정된 러너에 → returned None, leftover [], wrapper called 0
# 되돌린 사본: runner.py·래퍼를 59be1c8 판으로 → -k "s4c or s1e or s9 or s4 or s1" → 2 failed / 6 passed / 1 skipped
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue36_prompt_transport.py) # 23 passed, 1 skipped
```
[VERDICT: PASS]
@@ -0,0 +1,131 @@
# 🔍 Review Report — PR #37 병합 전 독립 리뷰 (job `4ab12ae2`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
- **대상**: PR #37 `fix/issue-36` **@ `59be1c8`**, base `main 1debc7d` (v4.7.2). 6커밋, 11파일 `+765/−30`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T080014Z-2b3df0/1`. **identity 확인**: `HEAD = 59be1c89…cb49`, `HEAD^{tree} = a009c906…fab7` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-37`)·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4)에서만(래퍼는 `--dry-run` + `--agent human` + 도달 불가 브로커, 러너는 in-process 스텁), pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
## 결론 요약
**병합을 막을 결함은 없다.** 프롬프트는 러너·래퍼·lib.sh 도우미·옛 셸 러너·create 온보딩 다섯 경로 모두에서 파일로 간다(`grep -rn -- '--prompt '` 의 잔존 호출은 전부 셸 **함수** 인자이거나 문서/기본값이다). 비차단 지적 4건(H1–H4). 그중 **H1 은 재현한 실제 결함**이다: 파이썬 러너는 프롬프트 파일 **쓰기**가 실패하면(ENOSPC 등) 부분 파일을 `.mam` 에 남기고 예외를 그대로 던진다 — PR 이 셸 쪽 두 곳에서 고친 바로 그 부류가 러너에는 빠져 있다. 디스크 고갈 급의 경계 상황이라 차단으로 보지 않았지만, 릴리스 커밋이나 즉시 후속에서 세 줄로 닫을 것을 권한다.
## 1. 전달 경로의 완전성 (항목 1)
| 경로 | 이전 | 이제 | 근거 |
|---|---|---|---|
| 파이썬 러너 `_run_cmd` | `--prompt <text>` argv | `.mam/prompt-<loop_id>-XXXX.md` 에 쓰고 `--prompt-file` | `runner.py:467-502` |
| 래퍼 `submit` → `registry.py register` | `--prompt "$PROMPT"` | 파일로 왔으면 `--prompt-file` 그대로 전달, 텍스트로 왔으면 종전대로 | `multi-agent-mux-delegate-job:186-208` |
| 래퍼 loop 타입의 회차 `update` | `--prompt "$current_prompt"` | `$job_dir/prompt.md` 에 쓰고 `--prompt-file` | `:405-450` |
| `lib.sh` `delegate_submit_job` / `_delegate_submit_job_legacy` (제출 도우미 11곳의 종착) | argv | `_delegate_prompt_file` 로 `.mam/prompt-submit-XXXXXX` 에 쓰고 `--prompt-file`, 성공·실패 모두 `rm -f` | `lib.sh:2038-2046, 2049-2064, 2160-2174` |
| `lib_py/delegation/cli.py submit` | `--prompt` 필수 | `--prompt`/`--prompt-file` 상호 배타, register 로 그대로 전달 | `cli.py:29-33, 64-66` |
| 옛 셸 러너 `MAM_LOOP_PY=0` | `delegate_job_safe … --prompt "<text>"` (함수 인자) | 함수 안에서 `--prompt` 를 `.mam/prompt-shloop-XXXXXX` + `--prompt-file` 로 바꿔 래퍼 exec | `run_loop.sh:196-221`. `run_loop.sh` 의 잔존 `--prompt "…"` 11곳은 이 **함수** 호출이라 exec 을 거치지 않는다 |
| `create_session.sh --submit-job` | 주입문에 과제 본문 포함 | 과제는 `<jobs>/<id>/brief.md` 에 쓰고 pane 에는 **경로만** | `create_session.sh:625-646` |
### 1-1. 래퍼의 파일 처리 (실측, `--dry-run --agent human`)
- 상대 `--prompt-file "my prompts/p 1.md"` 는 **호출자 cwd 기준**으로 절대화된다(`:191`, `cd "$WORKDIR"` 이전) — `--workdir` 가 다른 곳이어도 찾는다. 상대 `--registry-dir "reg dir"` 는 종전대로 `--workdir` 기준이다(기존 의미 유지). 공백 경로 모두 안전.
- 등록된 `prompt` 는 **바이트 단위로 동일**(`'line1\nline2\n\n'`, 후행 개행 보존) — registry 가 파일을 직접 읽기 때문. 래퍼의 `$PROMPT` 변수(`$(<file)`)는 후행 개행을 잃지만 그것은 브리프 본문에만 쓰인다(H4).
- 래퍼는 호출자의 파일을 **지우지 않는다**(소유권은 호출자). `--prompt` 와 `--prompt-file` 동시 → 거부, 없는 파일 → `cannot read --prompt-file <절대경로>` 거부.
- 읽는 시점: register 는 래퍼의 첫 단계(`:207`)이고 래퍼는 잡이 끝날 때까지 블록하므로, 러너·lib.sh·셸 러너가 **래퍼 반환 후** 지우는 순서는 안전하다.
### 1-2. 임시 파일의 위치·권한·정리
| 생성자 | 위치 | 권한 | 정리 |
|---|---|---|---|
| 러너 | 제어 루트 `.mam/` (`self.mam_dir`) | `NamedTemporaryFile` → 0600 | `finally: os.unlink` — 성공·실패·**OSError(exec 실패)** 모두 (`:498-502`) |
| lib.sh | `${WORKSPACE_ROOT}/.mam/` | `mktemp` → 0600 | 제출 뒤 `rm -f`; **쓰기 실패 시 `rm -f` 후 return 1** (`lib.sh:2042`) |
| 셸 러너 | `$MAM_REAL_ROOT/.mam/` | `mktemp` → 0600 | 래퍼 반환 뒤 `rm -f`; 쓰기 실패 시 제거 + 원인 로그 + return 1 (`:201-205`) |
| create | `<jobs>/<id>/brief.md` (잡 디렉터리, 보존 대상) | umask | 실패 시 잡 `error` + exit 1 (`:632-637`) |
### H1 (비차단이지만 실제 결함, 재현) — 러너의 쓰기 실패는 부분 파일을 남기고 예외를 던진다
`runner.py:472-477` 의 `with NamedTemporaryFile(...) as fh: fh.write(p_text)` 는 `try` **바깥**에 있다. 파일이 만들어진 뒤 `write` 가 실패하면(ENOSPC·EIO) `finally` 의 `unlink` 에 닿지 않는다. 검증 경로의 러너를 in-process 로 띄우고 `write` 가 `OSError(28)` 을 내도록 감싼 결과:
```
RAISED: OSError [Errno 28] No space left on device ← delegate_job 밖으로 그대로 전파(트레이스백, 126 처리 아님)
leftover prompt files in .mam: ['prompt-loop-…-2ussg8e6.md'] ← 부분 파일 잔존
wrapper called: 0
```
같은 부류를 PR 은 `lib.sh:2042`(`|| { rm -f "$f"; return 1; }`)과 `run_loop.sh:201-205` 에서 고쳤고 S9 로 고정했지만, 러너에는 대응이 없고 테스트(S4/S4b)도 `subprocess.run` 의 OSError 만 다룬다. 브리프 항목 1 의 "쓰기 실패 시 부분 파일이 남지 않는지"에 대해 러너는 **아니오**다.
→ 권고: `with` 블록을 `try` 안으로 옮기고 `except OSError` 에서 `unlink` 후 `CompletedProcess(cmd, 126, "", "cannot write the prompt file: …")` 를 돌려주면 exec 실패와 같은 "실패 제출" 경로로 합쳐진다(3–4줄). `self.mam_dir.mkdir` 실패도 같은 블록에서 잡힌다.
## 2. 실패 처리 (항목 2)
- **exec 실패(OSError → rc 126)**: `_run_cmd` 가 `CompletedProcess(cmd, 126, "", "cannot start the wrapper: … (prompt N bytes, wrapper …)")` 를 돌려주므로 첫 시도·busy 재시도 모두 같은 "실패 제출" 경로로 들어간다. 재시도 실패 시 job id 가 없는 경우에도 이제 `log_error` 를 남긴다(`runner.py:525`, 이전에는 무언). `failed_submit_jobs` 처리는 바뀌지 않았다(id 가 있으면 등록, 없으면 `None` 반환 → 호출자가 `return 1`). S4/S4b 가 E2BIG·EAGAIN 으로 고정.
- **재심·중재 미등록 → rc 1**: `:1354-1357`, `:1376-1378`. 판정 잡이 등록조차 안 됐는데 "반론 기각"이나 "중재 없음"으로 흘러가던 것을 실패로 바꾼 것이라 맞다.
- **순응(compliance) 잡 `wait_for_job` 실패 → rc 1** (`:1392-1395`): 의도된 강화로 판단한다. 근거는 **정규 수정 잡과의 대칭**이다 — 일반 correction 잡도 `if not correct_jid or not self.wait_for_job(correct_jid): return 1` (`:1323-1326`) 로 끝난다. 순응 잡은 "반론이 기각됐으니 그대로 고쳐라"는 수정 잡의 변형이므로, 시간 초과·에러로 닫힌 잡을 두고 다음 리뷰 라운드로 가면 **바뀌지 않은 트리를 다시 리뷰**하게 된다. 루프가 `wait_for_job` 실패를 통과시키는 자리는 이제 self-review(`:1187`)뿐이고 그것은 판정 리포트로 따로 걸러진다. S3c 가 세 경로(comply/adjudication/arbitration)의 미등록을 고정한다.
## 3. `MAM_DIFF_MAX_BYTES`/`MAM_DIFF_MAX_LINES` (항목 3)
- `diff_collect.sh:9-11` 주석이 "증거 크기의 상한이지 전달 상한이 아니다"를 명시하고, 상한값·잘림 마커·`runner.py` 의 l15 규칙(잘린 diff 면 diff-only 원격 리뷰어 거부, 로컬 진행)은 **변경되지 않았다**(runner 의 해당 블록은 diff 밖). S5 가 "옛 사각지대"(131,072 < diff < 200,000 바이트)의 diff 가 통째로 전달됨을 고정한다.
- **H2 (비차단, 문서)**: loop SKILL.md 에는 이 두 변수도, 잘림 마커의 의미도 적혀 있지 않다(grep 0건). delegate-job SKILL.md 는 argv 상한만 말한다. "전달은 무제한, 증거는 200 KB/4,000 줄에서 잘리며 잘린 diff 는 diff-only 원격 리뷰어에게 증거가 못 된다"는 한 문단이 loop SKILL.md 에 있어야 항목 3 의 "일관 반영"이 완성된다.
## 4. `create_session.sh` (항목 4)
- 잡 등록은 종전대로 lib.sh 의 `delegate_submit_job`(파일 경유)이고, 그 뒤 `<jobs>/<id>/brief.md` 에 과제를 쓰고 pane 에는 `Task: read the complete task in <절대경로> and follow it.` 만 주입한다(`:625-646`). `$pub`(`delegate_publish_cmd` 의 `--expect-dispatch-token`·`--attempt-id`)와 `started` 기반 판정은 **손대지 않았다** — #23 계약 유지. S8 이 주입문에 brief 경로는 있고 과제 본문은 없음, `--expect-dispatch-token`·`--event started` 가 남아 있음을 단언한다.
- 경로: `${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}` 를 절대화하므로 워크트리 creator(제어 루트 바인딩)도 자기 cwd 와 무관하게 읽을 수 있다. 래퍼가 만드는 `brief.md` 와 같은 자리다.
- 실패 시 잡 `error` + `JOB_TERMINALIZED=1` + exit 1 → 기존 트랩이 세션을 되돌린다(초기화 실패와 같은 취급).
## 5. 새 테스트 22건 (항목 5)
- **실제 경로를 지나는가**: S1 은 실제 래퍼(`bash multi-agent-mux-delegate-job submit --dry-run`)와 실제 `registry.py` 로 140,000 바이트를 등록해 **바이트 단위 동일**을 단언한다. S2/S3/S3b/S3c 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 받은 인자를 검사한다. S7/S7b/S9 는 `run_loop.sh`(MAM_LOOP_PY=0)를 실제로 돈다. S8 은 mock herdr 위에서 `create_session.sh` 를 실제로 돈다. mock 으로 우회하는 것은 S4/S4b(`subprocess.run` 을 OSError 로 대체 — 커널 대신 코드 경로를 검증하는 것이 목적)뿐이다.
- **macOS**: `MAX_ARG_STRLEN` 이 없어 단일 인자 150 KB 가 통과하므로 S1b(음성 대조군)는 `linux_only` 로 **skip** 되고, S8 의 `strace` 검사는 Linux 에서만 붙는다. 즉 이 호스트에서 스위트는 **커널 상한이 아니라 코드 경로**(파일로 전달됐는가)를 검증한다 — 브리프의 우려 그대로이며, 설계상 옳다. 실제 상한 재현은 Linux CI 몫.
- **격리**: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 를 `tmp_path` 로, 브로커를 `127.0.0.1:9` 로 고정하고 `MAM_JOBS_DIR`/`MAM_REAL_ROOT` 를 제거한다. 실제 `.mam`·홈 접촉 없음(§6 실측).
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 8파일을 `1debc7d` 판으로 되돌리고 새 파일을 돌린 결과 **18 failed / 3 passed / 1 skipped(S1b)**. 통과 3건은 정정 전에도 성립하던 것(래퍼 실패 → rc 1 등)이다.
## 6. 검증 경로에서의 테스트 실행 (항목 5·6)
```
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
tests/test_loop_cli.py tests/test_issue21_loop_b.py tests/test_issue21_acceptance.py tests/test_issue23_24_startup_path.py
tests/test_issue25_plan_reuse.py
→ 1 failed, 237 passed, 1 skipped in 422.71s (0:07:02)
```
- **skip 1**: S1b(`linux_only`) — 설계된 것(§5).
- **failed 1: S9 — 코드 결함이 아니라 테스트의 macOS 이식성 문제(H5).** S9 는 `ulimit -f 1` 로 쓰기를 실제로 실패시킨 뒤 세 가지를 단언한다: rc≠0, stderr 에 `File too large`, 부분 파일 없음 + 원인 문구. 같은 하네스를 scratchpad 에서 직접 돌린 결과 **rc=1, 부분 파일 없음, `delegate_job_safe: cannot write the prompt file` 출력** — 즉 PR 이 고친 동작(제거 + 원인 표시)은 macOS 에서도 성립한다. 실패한 것은 두 번째 단언뿐이다: macOS 의 `/bin/bash` 3.2 는 `printf` 의 쓰기 실패에 대해 `printf: write error: File too large` 문자열을 내지 않는다(Linux bash 5 의 형식). `strerror(EFBIG)` 자체는 macOS 에서도 "File too large" 이므로 순수하게 bash 버전 차이다. S1b 처럼 `linux_only` 로 두거나, 그 단언을 빼고 원인 문구 단언만 남기면 된다.
- 기존 계약 스위트(issue5 phase1 의 registry CLI 스냅샷·help 캡처, issue14, loop_cli, issue21 loop B·acceptance, issue23/24 S7*, issue25)는 **전부 통과** — `--prompt` 하위 호환과 온보딩 계약이 유지된다(항목 4·6).
### 격리·위생 실측
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
| 실제 `.mam` 의 `prompt-*` 잔존 | **0** |
| 검증 경로 `.mam` 의 `prompt-*` 잔존 | **0** (러너·셸 러너·lib.sh 의 정리가 실제 실행에서도 성립) |
| 검증 경로 tracked 파일 | 변화 없음(HEAD `59be1c8`), 본 체크아웃 `main 1debc7d` |
| 실행 시간 | 새 파일 22건은 34 s 안팎(되돌린 사본 기준 34.5 s); 전체 7 분은 issue14/18 계열의 벽시계 시험 몫 |
## 7. 문서 (항목 7)
- delegate-job SKILL.md `:44-48`: `--prompt-file` 의 의미와 argv 상한, "루프는 항상 파일을 쓴다" 명시 ✓. 래퍼 usage(`:112-121`)도 갱신 ✓.
- loop SKILL.md `:267`: 파이프라인에서의 종료 코드(`pipefail`/`PIPESTATUS`) 안내 ✓. 그러나 `MAM_DIFF_MAX_*` 의 새 의미는 없다(H2).
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`)라 자동 동기화된다 — 배포 사본 정합 문제 없음.
## 8. 병합 차단 여부 (항목 8)
버전 락스텝 bump 부재(`lib.sh:32` `4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 다만 H1 은 릴리스 커밋에 함께 넣거나 즉시 후속으로 닫기를 권한다 — 셸 쪽과 같은 부류이고 수정이 세 줄이다.
| # | 지적 | 성격 | 근거 |
|---|---|---|---|
| H1 | 러너의 프롬프트 파일 **쓰기** 실패 시 부분 파일 잔존 + 예외 전파(126 실패 제출로 합류하지 않음) — 재현 | 비차단(경계 상황), **후속 권고 1순위** | `runner.py:472-477` vs `:490-502`; §1 H1 |
| H2 | `MAM_DIFF_MAX_*` 의 "증거 상한, 전달 무제한" 의미가 loop SKILL.md 에 없음 | 문서 | `diff_collect.sh:9-11` 에만 있음 |
| H3 | 래퍼는 **빈** `--prompt-file` 을 받아들여 빈 프롬프트를 등록한다(실측) | 사소 | `multi-agent-mux-delegate-job:195` 의 `-n "$PROMPT" \|\| -n "$PROMPT_FILE"` |
| H4 | 래퍼의 `$PROMPT="$(<file)"` 는 후행 개행을 잃는다 — registry 의 프롬프트(파일 그대로)와 브리프 본문이 그 점에서 다를 수 있음 | 사소 | `:193` |
| H5 | S9 의 `File too large` 단언이 macOS bash 3.2 에서 실패(문자열 형식 차이); 실질 단언 2개는 통과 | 테스트 이식성 | §6 |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 59be1c8… / a009c90…
grep -rn -- '--prompt ' "$RP/.agents/skills" | grep -v '\.md:' # 래퍼 usage/기본값, cli 주석, run_loop.sh 의 함수 호출뿐
# H1 재현: 러너 in-process, NamedTemporaryFile.write 가 OSError(28) → .mam/prompt-* 잔존 + 예외 전파 (§1 H1 의 스크립트)
# S9 하네스 직접 실행(macOS): rc=1, 부분 파일 없음, "cannot write the prompt file" 출력, 단 "File too large" 문자열 없음
# 되돌린 사본: 소스 8파일을 1debc7d 판으로 → tests/test_issue36_prompt_transport.py → 18 failed / 3 passed / 1 skipped
```
[VERDICT: PASS]
@@ -0,0 +1,66 @@
# PR #37 독립 리뷰
- Job: `2e264c0c`; reviewer: `planner-reviewer-codex-01`
- Loop: `loop-20260924T080014Z-2b3df0`
- Base: `1debc7d92444385b9200e821efa0ff0f6d82023e`
- Head: `59be1c89209a232059aec0c5aeecc2329929cb49`
- Tree: `a009c9062d9818f343ac57e6c72cda932887fab7`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1`
파일 전달의 정상 경로와 기존 계약은 검증됐지만, Python 러너의 파일 쓰기 실패 정리 누락과 새 테스트의 macOS 실패가 남아 있다. 아래 두 항목을 수정한 뒤 재검토가 필요하다. 계획된 4.7.3 버전 변경과 별개의 지적이다.
## R1 — P2: Python 러너의 임시 파일 쓰기 실패가 정리 범위 밖에 있음
근거: `.agents/skills/lib_py/loop/runner.py:472`–`:477`, `:490`–`:502`.
`NamedTemporaryFile(delete=False)` 생성과 `fh.write()` 및 context manager 종료 시 flush/close가 `try/except/finally`보다 먼저 실행된다. 따라서 쓰기 또는 flush가 실패하면 `OSError`가 `delegate_job()` 밖으로 전파되고, 뒤의 `os.unlink(prompt_file)`에 도달하지 않는다. 특히 경로 변수도 쓰기 다음에 지정되어 있어 단순히 try 위치만 바꾸는 것으로는 충분하지 않다. 새 S9는 셸 러너의 부분 파일 정리만 검사하므로 이 Python 경로를 검출하지 못한다.
재현 절차:
1. 독립 Python subprocess에서 `WORKSPACE_ROOT`, `HOME`, 로그 경로를 새 `/tmp` 샌드박스로 설정하고, 검증 트리의 `LoopRunner(['--creator', 'c', '--task', 't'])`를 생성한다.
2. 그 subprocess에서만 `signal.signal(signal.SIGXFSZ, signal.SIG_IGN)`과 `resource.setrlimit(resource.RLIMIT_FSIZE, (1024, 1024))`를 적용한다.
3. `runner.delegate_job('c', 'claude', 'Worker', 'x' * 20000)`를 호출하고 예외와 `.mam/prompt-*`를 확인한다. 실제 wrapper/에이전트가 실행되기 전에 파일 쓰기에서 실패한다.
기대: 원인을 기록하는 정상적인 제출 실패 반환과 임시 파일 정리.
실제: `[Errno 27] File too large`가 처리되지 않은 `OSError`로 전파됐고, `.mam/prompt-loop-…md`가 **1,024바이트, 권한 0600**으로 남았다. 실제 `LoopRunner.delegate_job()`으로 재현했으며, 오류를 흉내 내는 subprocess mock은 사용하지 않았다. 생성/쓰기 실패를 처리해야 한다는 이번 브리프의 요구를 충족하지 못한다.
수정 방향: 임시 파일 경로를 미리 초기화하고, 디렉터리 생성·파일 생성·쓰기·close·wrapper 실행을 정리 가능한 하나의 try/finally 범위에 둔다. 생성된 파일의 이름은 `write()` 전에 저장한다. 파일 준비 실패도 원인이 드러나는 실패 제출 결과로 돌려주고, 첫 시도와 busy 재시도에서 같은 규칙을 적용한다. 메시지는 wrapper 실행 실패와 파일 준비 실패를 구분하는 것이 정확하다.
대안 검증: 검증 트리에서 추출한 동일 `_run_cmd`를 임시 harness에서만 변경해, 위 작업들을 기존 예외/정리 범위 안으로 옮기고 경로를 쓰기 전에 저장했다. 같은 실제 파일 크기 제한에서 원본은 예외와 1,024바이트 파일을 남겼지만, 후보는 **rc 126 실패 결과와 빈 leftovers 목록**을 반환했다. 제품 코드는 수정하지 않았다. 이 상황을 Python 러너의 회귀 테스트에도 추가해야 한다.
## R2 — P2: 새 S9가 macOS에서 플랫폼별 오류 문구 때문에 실패함
근거: `tests/test_issue36_prompt_transport.py:602`.
재현: 브리프의 인터프리터와 격리 옵션으로 `tests/test_issue36_prompt_transport.py::test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why`를 실행한다.
기대: 실제 `RLIMIT_FSIZE` 쓰기 실패, 비정상 종료, 부분 파일 제거, 프로젝트가 출력하는 원인 메시지를 검증하며 지원 플랫폼에서 통과해야 한다.
실제: 전체 선택 실행과 S9 단독 재실행 모두 `assert "File too large" in result.stderr`에서 실패했다. 이 macOS 실행 환경의 bash stderr에는 그 문구가 없었다. 프로젝트 자체의 `delegate_job_safe: cannot write the prompt file ...` 메시지는 출력됐다.
수정 방향 및 검증: 플랫폼별 bash/OS 오류 문자열을 필수 계약으로 삼지 말고, 비정상 반환 코드·부분 파일 없음·프로젝트의 고유 오류 메시지를 유지해서 검사한다. 임시 namespace에서 해당 영어 문자열 assertion 한 줄만 제외한 동일 S9를 새 샌드박스에 실행했으며, 나머지 **실제 쓰기 실패와 정리/진단 assertion은 모두 통과**했다. 테스트 전체를 skip하거나 정리 assertion을 제거할 필요는 없다.
## 나머지 검토 결과
- 전달 경로: `--prompt` 잔여 호출자를 직접 검색했다. 옛 셸 러너의 11개 내부 호출은 shell 함수 `delegate_job_safe`로 들어가며, 이 함수가 exec 전에 파일로 변환한다. Python 러너, lib.sh의 Python/legacy 제출 도우미, delegation CLI, wrapper의 registry register/update 경로는 파일 전달을 사용한다. 명시적 `--prompt` CLI 호환 경로는 남아 있다.
- 경로/크기: 실제 이 호스트의 `SC_ARG_MAX`보다 큰 **1,114,112바이트** 프롬프트를 argv로 전달하면 `OSError(errno=7)`가 발생했다. 같은 내용은 상대경로의 공백 포함 파일명, 다른 `--workdir`, 공백 포함 registry 경로 조합으로 실제 wrapper/registry를 거쳐 원문 그대로 등록됐다. Linux 단일 인자 131,072바이트 상한을 macOS에서 재현했다고 주장하는 것이 아니다.
- 파일 읽기 시점: wrapper가 registry를 동기 실행하고 registry가 그 호출 중 파일 내용을 읽으므로, 호출자 쪽 정상 반환 후 임시 파일 삭제와 충돌하지 않는다. 정상 완료/exec OSError의 정리 테스트는 통과했다. R1은 그보다 앞선 쓰기 실패에 한정된다.
- 실패 처리: exec 실패와 busy 재시도의 rc 126 처리, 재심/중재/순응 미등록 시 비정상 종료 테스트가 통과했다. 별도 실제 루프 subprocess에서 fake agent가 순응 잡을 등록한 뒤 `error` 상태로 종료하도록 했을 때도 rc 1로 끝났고 다음 리뷰 라운드/성공 승격으로 진행하지 않았다. 구현·일반 수정 잡의 실패를 중단하는 기존 정책과 일관된 강화다. 다만 이때도 “Failed to register Creator compliance job”이라고 출력하는 문구는 등록 실패와 실행 실패를 구분하지 못한다(비차단 진단 개선 사항).
- diff 제한: 수집기의 바이트/줄 제한값은 바뀌지 않았고 증거 절단 표시도 유지된다. L15는 잘린 증거를 받는 diff-only 원격 리뷰어를 거부하고 로컬 리뷰는 허용하는 기존 규칙을 검증했다.
- create 온보딩: S7 계열과 새 S8이 통과했다. brief는 job registry 아래 절대경로로 만들어지고, 주입문은 과제 본문 대신 그 경로와 기존 dispatch-token/started 명령을 포함한다. 임시 제출 파일과 에이전트가 이후 읽을 영속 brief는 구분된다.
- 하위 호환: registry CLI 계약 스냅샷, shim help 캡처, loop CLI, worktree 문맥, Issue #14 acknowledgement 선택 테스트가 통과했다. `--prompt`는 계속 사용할 수 있다. 대표 역검증으로 샌드박스의 wrapper만 base 버전으로 교체하면 새 S1은 `unknown option: --prompt-file`로 실패했다. PR이 언급한 13건 전체의 과거 버전 실패 수를 재측정한 것은 아니다.
- 문서: delegate-job skill은 파일 옵션과 Linux argv 제한을 설명하고, loop skill은 위임 실패의 비정상 종료 및 pipefail 사용을 설명한다. 검증 트리에 `.claude/skills` 배포 사본은 없었다. 4.7.3 락스텝 bump는 브리프대로 오케스트레이터 후속 작업이다.
## 실행 증거
모든 pytest 실행은 검증 경로에서 브리프의 절대 Python 경로를 `AGENT_PYTHON_BIN`과 실행 명령에 함께 지정하고, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 경로 밖의 고유 `--basetemp`를 사용했다.
1. `test_issue36_prompt_transport.py`, `test_issue5_phase1_delegation.py`, `test_loop_cli.py`: **95 passed, 1 failed, 1 skipped — 91.15초**. 실패는 R2의 S9이며, skip은 Linux 전용 MAX_ARG_STRLEN 음성 대조군이다.
2. `test_issue23_24_startup_path.py`, `test_issue21_loop_b.py`, `test_issue21_worktree_prereqs.py`, `test_issue14_delivery_ack.py`에서 `s7 or l15 or t2_5 or wrapper_honours_registry_ack` 선택: **19 passed, 132 deselected — 63.39초**.
3. S9 단독 확인: 동일 문구 assertion에서 실패. 위 결과에 중복 합산하지 않았다.
4. `git diff --check`, 변경 셸 파일 5개의 개별 `bash -n`, 변경 Python 파일 4개의 AST 구문 검사 통과.
HEAD/tree가 브리프와 일치했고 검증 후 git 작업 트리도 깨끗했다. 제품 코드, creator 워크트리, 실제 herdr 서버/에이전트 세션을 수정하지 않았다. 제안 검증과 역검증은 임시 사본/독립 subprocess에 한정했다. 두 지적은 국소 수정으로 해결 가능하므로 재설계 에스컬레이션은 필요하지 않다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260924T085626Z-b8987e",
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
"reviewer": "planner-reviewer-codex-01",
"job_id": "d493b472",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-24T09:09:43Z"
}
@@ -0,0 +1,57 @@
# PR #37 델타 리뷰 2회차
- Job: `d493b472`; reviewer: `planner-reviewer-codex-01`
- Loop: `loop-20260924T085626Z-b8987e`
- Base: `59be1c89209a232059aec0c5aeecc2329929cb49`
- Head: `708262a598c4301c0ccc70efba5401093b4b1e30`
- Tree: `d601c2d01bb4c09931e5e1989e140b01d51f5b96`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1`
직전 리뷰의 R1/R2가 해결됐고, 추가된 빈 파일 거부도 기존 wrapper 계약과 일치한다. 이번 델타에서 병합 차단 결함을 발견하지 않았다. 브리프에 명시된 4.7.3 락스텝 버전 변경은 오케스트레이터 후속 작업이다.
## R1/H1 — 파일 쓰기 실패 처리 및 정리: 해소
근거: `.agents/skills/lib_py/loop/runner.py:472`–`:491`, `:504`–`:516`.
경로를 빈 문자열로 초기화하고, 디렉터리 생성·임시 파일 생성·쓰기·context manager 종료를 새 try 범위에 포함했다. 파일 이름을 쓰기 전에 저장하므로 쓰기 또는 닫기 중 오류에서도 부분 파일을 지울 수 있다. 오류 경로는 원인이 포함된 rc 126의 `CompletedProcess`를 반환하며, 뒤의 wrapper 실행 try/finally에 들어가지 않는다. 정상 경로에서는 기존 finally가 파일을 지운다. 따라서 두 정리 경로가 같은 파일에 중복 적용되지 않는다.
실제 `RLIMIT_FSIZE=1024`와 `SIGXFSZ` 무시를 독립 subprocess에 적용해 재검증했다. 제품 코드에서 추출한 동일 `_run_cmd`에 실행/삭제 횟수 관찰만 추가한 결과:
| 조건 | 반환 코드 | wrapper 실행 시도 | 파일 삭제 호출 | 잔여 파일 |
|---|---:|---:|---:|---|
| 20,000바이트 쓰기 중 실제 EFBIG | 126 | 0 | 1 | 없음 |
| 2,000바이트 버퍼의 close/flush 시 실제 EFBIG | 126 | 0 | 1 | 없음 |
| 정상 실행 | 0 | 1 | 1 | 없음 |
| exec 실패(PATH에서 bash 제거) | 126 | 1 | 1 | 없음 |
| `.mam`이 디렉터리가 아닌 파일 | 126 | 0 | 0 | 없음 |
추출 함수뿐 아니라 실제 `LoopRunner.delegate_job()`도 별도 subprocess에서 같은 20,000바이트 쓰기 실패로 실행했다. 결과는 `None`, `delegate_job submit failed (exit 126)` 및 파일 쓰기 원인 로그, 잔여 파일 없음이었다.
busy 재시도 경로는 첫 wrapper 호출만 rc 5로 모사하고 그때 실제 파일 크기 제한을 설정했다. 두 번째 시도의 파일 쓰기가 EFBIG로 실패하자 `retry failed (exit 126)`이 기록됐고, wrapper는 한 번만 호출됐으며 결과는 `None`, 잔여 파일은 없었다. 미등록 상태에서 가짜 job ID나 `failed_submit_jobs` 항목을 만들지 않고 기존 실패 제출 흐름으로 돌아간다.
역검증: S4c에만 base의 `delegate_job` 메서드를 임시 namespace로 적용했다. 새 회귀는 처리되지 않은 ENOSPC로 실패했고 파일 한 개가 남았다. 수정된 HEAD의 S4c는 통과했다. 기존 R1 지적을 해소한다.
## R2/N1 — macOS S9 오류 문구: 해소
근거: `tests/test_issue36_prompt_transport.py:637` 이후.
libc/bash의 `File too large` 문구는 Linux에서만 요구한다. 비정상 반환 코드, 잔여 파일 없음, 프로젝트 고유의 `delegate_job_safe: cannot write the prompt file` 진단은 모든 플랫폼에서 여전히 검사한다. 현재 macOS에서 S9가 통과했다. 쓰기 실패와 정리의 검증 의도를 유지한 이식성 수정이다.
## H3 — 빈 프롬프트 파일 거부: 적절
근거: `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:193`.
읽을 수 있는 일반 파일 확인 다음에 `-s` 검사를 추가하므로, 0바이트 입력은 registry 등록 전에 거부된다. 기존 wrapper의 `--prompt ''`도 허용하지 않았으므로 유효한 기존 wrapper 사용처를 차단하는 변경은 아니다. registry 자체의 register/update 구현은 이번 델타에서 바뀌지 않았다. loop 형의 내부 `prompt.md`는 registry update에 직접 전달되므로 이 입구 검사와 혼동되지 않는다. 공백/개행으로만 된 비어 있지 않은 파일을 거부하는 의미로 확대된 변경도 아니다.
S1e가 통과했다. 샌드박스 skills 사본의 wrapper만 base로 교체하고 같은 S1e를 호출하면 assertion이 실패하며 빈 프롬프트 잡 한 개가 등록됐다. 새 검사가 기존 결함을 검출한다는 역검증이다.
## 검증 및 범위
- `git diff 59be1c8...HEAD`의 변경 파일 3개를 검토했다. HEAD/tree가 브리프와 일치했고 검증 후 작업 트리도 깨끗했다.
- `tests/test_issue36_prompt_transport.py` 전체: **23 passed, 1 skipped in 42.24s**. skip은 Linux 전용 MAX_ARG_STRLEN 음성 대조군이다. 기존 파일 전달, exec 오류/재시도, 셸 루프, create 온보딩 테스트도 이 실행에 포함된다.
- pytest에는 브리프의 절대 Python 경로를 실행 명령과 `AGENT_PYTHON_BIN`에 지정하고 `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 경로 밖의 고유 `/tmp/mam-review-d493b472.*` basetemp를 사용했다.
- `git diff --check`, 변경 wrapper의 `bash -n`, 변경 Python 파일 2개의 AST 구문 검사 통과.
- 위 실패 주입, 역검증, 실행/삭제 횟수 검사는 별도 임시 경로와 독립 subprocess에서 수행했다. 제품 코드, creator 워크트리, 실제 herdr 서버/에이전트 세션은 수정하지 않았다.
- 직전 리뷰의 넓은 계약 테스트 전체를 이번 트리에서 재실행했다고 주장하지 않는다. 이번 검증은 정정된 세 항목과 프롬프트 전달 회귀에 집중했다. 이전 판정을 유지할 추가 차단 사유를 발견하지 못했으며 재설계 에스컬레이션은 필요하지 않다.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260924T085626Z-b8987e",
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
"reviewer": "reviewer-agy-01",
"job_id": "3121d389",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-24T09:09:43Z"
}
@@ -0,0 +1,76 @@
# Pre-Merge Independent Delta Review Report: PR #37 (`fix/issue-36` Round 2)
- **Job ID**: `3121d389`
- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`)
- **Role**: Reviewer
- **Target Worktree Context**:
- **Loop ID**: `loop-20260924T085626Z-b8987e`
- **Base Commit**: `59be1c89209a232059aec0c5aeecc2329929cb49` (Round 1 target)
- **Head Commit**: `708262a598c4301c0ccc70efba5401093b4b1e30` (1 delta commit: +68 / −7)
- **Tree SHA**: `d601c2d01bb4c09931e5e1989e140b01d51f5b96`
- **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1`
---
## 1. Executive Summary
This second review round examines delta commit `708262a` ("fix(issue-36): merge review - handle prompt write failure in runner; reject empty prompt file; make test_s9 stderr check portable").
The commit directly resolves all three findings identified during Round 1:
1. **R1 / H1 (Prompt Write Failure Handling in `runner.py`)**: `_run_cmd` now wraps temporary prompt file creation and writing in `try ... except OSError:`. If writing fails (e.g. `ENOSPC`, `EFBIG`), any partial prompt file is unlinked and a `CompletedProcess(..., 126, "", msg)` is returned. The wrapper is not invoked, no unhandled exceptions bubble to `LoopRunner.run()`, and the failure is logged cleanly. Verified by new test `test_s4c`.
2. **R2 / N1 (macOS Portability in `test_s9`)**: In `tests/test_issue36_prompt_transport.py`, line 637 guards the assertion `assert "File too large" in result.stderr` with `if sys.platform == "linux":`. This accommodates macOS `/bin/bash` 3.2.57 behavior while continuing to assert `result.returncode != 0`, clean file removal (`assert not leftovers`), and the portable MAM diagnostic (`assert "delegate_job_safe: cannot write the prompt file" in result.stderr`) across all platforms.
3. **H3 (Empty Prompt File Rejection)**: `multi-agent-mux-delegate-job` now asserts `[[ -s "$PROMPT_FILE" ]]` in `cmd_submit()`, rejecting empty files before registration. Verified by new test `test_s1e`.
All 23 applicable tests in `tests/test_issue36_prompt_transport.py` passed cleanly in the macOS verification environment (with 1 platform-specific test skipped on Darwin as intended).
---
## 2. Detailed Verification Against Checklist Items
### (1) Verification of the Three Delta Fixes & Side-Effect Analysis
- **Prompt File Creation & Cleanup in `runner.py` (R1/H1)**:
- `prompt_file` is initialized to `""`.
- In `_run_cmd`, `tempfile.NamedTemporaryFile` and `fh.write(p_text)` are wrapped in `try ... except OSError as e:`.
- If writing fails:
* If `prompt_file` exists, it is unlinked immediately in the `except` block.
* Returns `subprocess.CompletedProcess(["bash", str(wrapper_script), "submit"], 126, "", msg)`.
* Because it returns directly from the `except` block, the downstream `subprocess.run(cmd)` and the outer `finally:` block do not execute. The wrapper is never invoked with an incomplete file.
- On the normal path:
* The wrapper executes, and the outer `finally: os.unlink(prompt_file)` ensures the file is deleted exactly once.
- The returned exit code 126 triggers the existing retry mechanism in `delegate_job()`; if the write still fails, the error message is logged cleanly and `delegate_job()` returns `None`, terminating the loop with exit code 1 without tracebacks.
- Verified by `test_s4c_a_prompt_file_that_cannot_be_written_is_a_failed_submit`.
- **macOS Test Portability for `test_s9` (R2/N1)**:
- Scoping `assert "File too large" in result.stderr` to `sys.platform == "linux"` eliminates the macOS `AssertionError` that caused the Round 1 `NOT PASS`.
- Portable assertions across Darwin and Linux verify:
* `assert result.returncode != 0` (non-zero exit upon write failure).
* `assert not leftovers` (partial `.mam/prompt-shloop-*` files are deleted).
* `assert "delegate_job_safe: cannot write the prompt file" in result.stderr` (MAM error diagnostics printed to stderr).
- Verified by running `tests/test_issue36_prompt_transport.py::test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why` on macOS (PASS).
- **Empty Prompt File Rejection (H3)**:
- `cmd_submit()` in `multi-agent-mux-delegate-job` checks `[[ -s "$PROMPT_FILE" ]] || { echo "submit: --prompt-file $PROMPT_FILE is empty" >&2; exit 1; }`.
- Rejects zero-byte prompt files fail-closed before invoking `registry.py`.
- Verified by `test_s1e_an_empty_prompt_file_is_refused`.
### (2) Impact of Empty File Rejection on Valid Workflows
- In MAM, all valid delegated prompts (task instructions, review requests, refinement briefs, compliance orders) require meaningful textual context.
- Loop brief provisioning (`prompt.md`) always writes non-empty markdown briefs containing metadata and task descriptions.
- Enforcing `-s` prevents unintended empty job dispatches without affecting legitimate system workflows.
### (3) Overall Merge Readiness
- Commit `708262a` cleanly addresses all review concerns with minimal, surgical changes (+68 / −7).
- `git diff 59be1c8...HEAD --check` reported 0 whitespace or formatting errors.
- Version bump to `v4.7.3` is scheduled for the orchestrator release commit.
- Zero blockers remaining.
---
## 3. Test Execution Summary
| Test Suite / Target | Result | Duration | Notes |
| :--- | :--- | :--- | :--- |
| `tests/test_issue36_prompt_transport.py` | **23 PASS, 1 SKIP** | 42.4s | 23 passed on macOS; `test_s1b` skipped (`linux_only` constant test); `test_s9`, `test_s4c`, `test_s1e` all pass |
| `git diff 59be1c8...HEAD --check` | **0 errors** | 0.05s | Clean formatting and no whitespace errors |
---
[VERDICT: PASS]
@@ -0,0 +1,135 @@
# Pre-Merge Independent Review Report: PR #37 (`fix/issue-36`)
- **Job ID**: `db46a0da`
- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`)
- **Role**: Reviewer
- **Target Worktree Context**:
- **Loop ID**: `loop-20260924T080014Z-2b3df0`
- **Base Commit**: `1debc7d92444385b9200e821efa0ff0f6d82023e` (v4.7.2)
- **Head Commit**: `59be1c89209a232059aec0c5aeecc2329929cb49` (6 commits: +765 / −30)
- **Tree SHA**: `a009c9062d9818f343ac57e6c72cda932887fab7`
- **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1`
---
## 1. Executive Summary
PR #37 resolves Issue #36 ("위임 프롬프트를 argv 가 아닌 파일로 전달하고, 위임 실패를 종료 코드 1 로 보고", Closes #36) across 6 commits and 11 files (+765 / −30):
- `59be1c8` fix(issue-36): create_session hands the agent a brief path, not the task; a prompt file that cannot be written is removed
- `f26952c fix(issue-36): the legacy shell loop and lib.sh's submit helpers pass prompts by file
- `14d11bf` fix(issue-36): a job that cannot be registered after a rebuttal ends the loop with rc 1
- `f5cadd9` test(issue-36): prompts over the argv cap go through the real wrapper, registry and loop; a failed delegation ends the loop with rc 1 and no traceback
- `c77806b` fix(issue-36): the loop passes every delegated prompt as a file; a wrapper that cannot start is a failed submit, on the first try and on the retry
- `68a5d31` fix(issue-36): registry and wrapper accept --prompt-file; the wrapper hands the same path on
The core architecture correctly eliminates the 131,072-byte `MAX_ARG_STRLEN` barrier on Linux by passing prompts via temporary files (`--prompt-file`) across the Python loop runner, delegate-job wrapper, `registry.py`, `lib.sh`, and `create_session.sh`. All legacy CLI contracts and `--prompt` backward compatibility remain intact.
However, during execution in the verification environment on macOS, test `test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why` in `tests/test_issue36_prompt_transport.py` **failed** with an `AssertionError` at line 602 (`assert "File too large" in result.stderr`). This is a platform-dependent test expectation: GNU bash / glibc on Linux prints `write error: File too large` when `ulimit -f` is exceeded, whereas macOS `/bin/bash` (3.2.57) does not emit that string. Because the test suite must pass cleanly across supported platforms (macOS/Linux), a verdict of `NOT PASS` is issued.
---
## 2. Blocking Defect Details
### [Defect 1] Platform-Specific Assertion Failure on macOS in `test_s9`
- **File & Line**: `tests/test_issue36_prompt_transport.py:602`
- **Code**:
```python
598: harness.write_text('set -euo pipefail\nREPO_ROOT="$PWD"\nMAM_REAL_ROOT="$PWD"\n'
599: 'log_error() { printf "%s\\n" "$1"; }\n' + function +
600: '\ntrap "" XFSZ\nulimit -f 1\n'
601: 'output=$(delegate_job_safe submit --prompt "$(cat input.md)")\n')
602: result = subprocess.run(["bash", str(harness)], cwd=tmp_path, capture_output=True, text=True, timeout=60)
603: assert result.returncode != 0
604: assert "File too large" in result.stderr
605: leftovers = list((tmp_path / ".mam").glob("prompt-shloop-*"))
606: assert not leftovers, [(p.name, p.stat().st_size) for p in leftovers]
607: assert "delegate_job_safe: cannot write the prompt file" in result.stderr
```
- **Error Trace**:
```
FAILED tests/test_issue36_prompt_transport.py::test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why
AssertionError: assert 'File too large' in 'xxxx...xxxxdelegate_job_safe: cannot write the prompt file under /private/tmp/.../.mam\n'
```
- **Root Cause**:
When `ulimit -f 1` is exceeded during `printf '%s' "$2" > "$prompt_file"`:
* On Linux (GNU bash 5.x / glibc), the shell writes `bash: line X: printf: write error: File too large` to stderr.
* On macOS (Darwin / bash 3.2.57), the redirection write fails and `printf` exits non-zero without printing the exact literal `"File too large"`.
* The actual implementation behavior is completely correct on macOS: `result.returncode != 0` (Line 603 passes), partial files are cleaned up (`assert not leftovers` Line 606 passes), and MAM's error message `delegate_job_safe: cannot write the prompt file` is printed to stderr (Line 607 passes).
* Line 604 strictly demands `"File too large"` unconditionally, causing the test to fail on macOS.
- **Remediation**:
Guard the assertion with `sys.platform == "linux"`, or allow MAM's error string to satisfy the failure check:
```python
if sys.platform == "linux":
assert "File too large" in result.stderr
```
or
```python
assert ("File too large" in result.stderr or "cannot write the prompt file" in result.stderr)
```
---
## 3. Detailed Verification of Other Checklist Items
### (1) Completeness of Prompt Transport Pathways
- **No Residual Raw `--prompt` Exec Invocations**:
- `lib_py/loop/runner.py`: `_run_cmd` creates a temporary file under `self.mam_dir` (`prompt-{loop_id}-*.md`) and invokes the wrapper with `--prompt-file <path>`. The temporary file is safely cleaned up in a `finally:` block.
- `lib_py/delegation/registry.py`: Supports mutually exclusive `--prompt` and `--prompt-file` in both `register` and `update` commands. Evaluated via `_prompt_from_args()` during CLI parsing before writing to registry JSON.
- `lib_py/delegation/cli.py`: Supports `--prompt-file`, forwarding to `registry.py`.
- `lib.sh`: `delegate_submit_job()` and `_delegate_submit_job_legacy()` both utilize `_delegate_prompt_file` and clean up via `rm -f "$prompt_file"`.
- `multi-agent-mux-delegate-job`: Iteration brief provisions write to `$job_dir/prompt.md` and invoke `registry.py update --prompt-file "$job_dir/prompt.md"`.
- `run_loop.sh`: `delegate_job_safe` intercepts `--prompt` and converts it into `--prompt-file "$prompt_file"`, unlinking it upon return.
- `create_session.sh`: Injected pane text no longer includes the full prompt text; instead, writes `$SUBMIT_JOB_PROMPT` to `$task_brief` (`.mam/jobs/<id>/brief.md`) and passes: `Task: read the complete task in $task_brief and follow it.`.
- Relative paths passed to `--prompt-file` in `cmd_submit` are converted to absolute paths against caller `pwd` before changing directory to `WORKDIR` (`multi-agent-mux-delegate-job:384`), preventing directory-shift bugs.
### (2) Error and Failure Handling
- **`OSError` Handling on Wrapper Execution**:
- In `runner.py`:
```python
except OSError as e:
msg = (f"cannot start the wrapper: {e} "
f"(prompt {len(p_text.encode('utf-8'))} bytes, wrapper {wrapper_script})")
return subprocess.CompletedProcess(cmd, 126, "", msg)
```
Wraps execution failure cleanly as returncode 126. The loop logs the failure and aborts with rc 1 without tracebacks (`test_s3b`, `test_s4`).
- **Fail-Closed Unregistered / Failed Jobs**:
- Re-adjudication failure (`if not adj_jid:`): terminates with error and rc 1.
- Arbitration failure (`if not arb_jid:`): terminates with error and rc 1.
- Compliance failure (`if not comp_jid or not self.wait_for_job(comp_jid):`): terminates with error and rc 1.
- Changing compliance failure from continue to `return 1` is an intentional, sound hardening: if the compliance job fails or times out after an overruled rebuttal, the Creator did not implement the required changes; proceeding to review un-updated code would be erroneous.
### (3) Evidence vs. Delivery Sizing (`MAM_DIFF_MAX_BYTES` / `MAM_DIFF_MAX_LINES`)
- In `diff_collect.sh`: Comments clarify that `MAM_DIFF_MAX_BYTES` (default 200,000) and `MAM_DIFF_MAX_LINES` (default 4000) bound the evidence shown to reviewers, while delivery itself is unbound due to `--prompt-file`.
- `test_s5_a_diff_in_the_former_blind_spot_is_delivered_whole` confirms a 150,000-byte diff (between 131,072 and 200,000 bytes) is not truncated and delivered intact.
### (4) `create_session.sh` Onboarding & Brief Alignment
- Task text is written to `.mam/jobs/<id>/brief.md`.
- Injected instruction provides the absolute path to `brief.md`.
- Handshake protocol (`delegate_publish_cmd`, `--event started`, token verification) remains 100% compliant with Issue #23. Verified by `test_issue23_24_startup_path.py` (19/19 tests passed).
### (5) Existing Test Contracts & Backwards Compatibility
- `test_issue5_phase1_delegation.py`: **66 / 66 passed**.
- `test_version_consistency.py`: **3 / 3 passed**.
- `test_loop_cli.py`: **9 / 9 passed**.
- `test_issue14_delivery_ack.py`: **12 / 12 passed**.
- `test_issue21_phase2_loop_a.py`: **29 / 29 passed**.
- Existing `--prompt` syntax remains fully supported for backward compatibility across all CLIs.
---
## 4. Test Execution Summary
| Test Suite | Result | Duration | Notes |
| :--- | :--- | :--- | :--- |
| `tests/test_issue36_prompt_transport.py` | **20 PASS, 1 FAIL, 1 SKIP** | 39.9s | `test_s9` failed on line 602 (`File too large` assertion on macOS); `test_s1b` skipped (`linux_only`) |
| `tests/test_issue5_phase1_delegation.py` | **66 / 66 PASS** | 12.1s | Registry CLI, backward compatibility with `--prompt` |
| `tests/test_issue23_24_startup_path.py` (S7/S8) | **19 / 19 PASS** | 63.1s | `create_session.sh` brief task injection & handshake |
| `tests/test_loop_cli.py` | **9 / 9 PASS** | 39.2s | Loop CLI regressions |
| `tests/test_issue14_delivery_ack.py` | **12 / 12 PASS** | 98.2s | Delivery acknowledgement contracts |
| `tests/test_issue21_phase2_loop_a.py` | **29 / 29 PASS** | 155.3s | Isolated worktree loop review regressions |
| `tests/test_version_consistency.py` | **3 / 3 PASS** | 0.3s | Version consistency checks |
| `git diff 1debc7d...HEAD --check` | **0 errors** | 0.05s | Clean formatting and no whitespace errors |
---
[VERDICT: NOT PASS]
@@ -0,0 +1,44 @@
# 🔍 Review Report — Job 93408cd8 (PR #37 @ `59be1c8`)
- **Reviewer**: `reviewer-grok-01` · 2026-09-24
- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1`
- **identity**: base `1debc7d` head `59be1c8` tree `a009c90` (일치)
- **본 체크아웃**: `main` `1debc7d` 유지
`git diff --check` 클린. 6커밋.
## NOT PASS
### N1. S9 가 macOS 에서 실패한다
`tests/test_issue36_prompt_transport.py:602` 가 stderr 에 리터럴 `File too large` 를 요구한다. 이 호스트(Darwin)에서 `ulimit -f 1` 로 `delegate_job_safe` 쓰기가 실패하면 rc 는 1 이고 stderr 는 이식 가능한 문장뿐이다:
`delegate_job_safe: cannot write the prompt file under …/.mam`
bash 가 libc 문구 `File too large` 를 찍지 않아 단언이 깨진다. 같은 스위트는 **1 failed, 99 passed, 1 skipped** (`s1b` 는 `linux_only`).
**대안**: 잔여 파일 없음과 `cannot write the prompt file` 은 모든 플랫폼에서 단언하고, `File too large` 는 Linux 에서만 보거나 빼라. 제품 경로(`run_loop.sh` 의 `rm -f` + stderr)는 이미 그 계약이다.
## 그 외 (N1 만 막음)
**(1) 전달.** 러너 `_run_cmd` 는 `--prompt-file` 만 (`runner.py:485`). 래퍼는 파일이면 그 경로를 registry 에 넘기고, 그 전에 읽는다 (`multi-agent-mux-delegate-job:188-199`). `cli.py` / `registry.py` 는 `--prompt` 와 `--prompt-file` 상호 배타. `lib.sh` 제출 두 곳과 `delegate_job_safe` 가 `--prompt` 를 파일로 바꾼다. 옛 셸 러너의 `--prompt` 호출은 모두 `delegate_job_safe` 를 탄다. 문서의 `--prompt` 예시는 하위 호환. 임시 파일은 제어 루트 `.mam/prompt-*`, 래퍼 반환 후 삭제. 셸 쓰기 실패는 `rm -f`.
**(2) 실패.** OSError → rc 126, 재시도도 같은 처리, 등록 실패면 `None` → 루프 rc 1. 재심·중재 **미등록** 은 rc 1. 순응 잡은 미등록뿐 아니라 `wait_for_job` 실패도 rc 1 (`runner.py:1393-1395`). 코드가 안 고쳐진 채 다음 라운드로 가면 안 되므로 강화가 맞다. 로그 문장이 wait 실패에도 “Failed to register” 인 것은 비차단.
**(3) diff 상한.** `diff_collect.sh` 주석: 증거만 자르고 전달은 파일. 잘린 diff 의 diff-only 원격 거부는 `runner.py:1204` 그대로.
**(4) create.** 주입문은 brief 절대경로만. `delegate_publish_cmd` 와 `--expect-dispatch-token` 유지. S8(100·130500) 통과.
**(5) 테스트.** 140KB 는 실제 wrapper/registry subprocess. S1b(E2BIG)는 Linux 전용 — macOS 는 커널 상한이 없어 skip 이 맞다. 경로는 파일을 검증한다. S8/S9 는 mock 이 아님.
**(6)–(8).** `--prompt` 짧은 문자열은 그대로. 4.7.3 bump 는 오케스트레이터 예정.
## 테스트
검증 경로, `AGENT_PYTHON_BIN` = 컨트롤 `.venv`, basetemp `/tmp`:
`test_issue36_prompt_transport.py` + `test_issue23_24_startup_path.py` + `test_loop_cli.py`
**1 failed, 99 passed, 1 skipped in 237.33s.**
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260924T085626Z-b8987e",
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
"reviewer": "reviewer-grok-01",
"job_id": "e44ac99c",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-24T09:09:43Z"
}
@@ -0,0 +1,36 @@
# 🔍 Review Report — Job e44ac99c (PR #37 델타 `59be1c8...708262a`)
- **Reviewer**: `reviewer-grok-01` · 2026-09-24
- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1`
- **identity**: head `708262a` tree `d601c2d` (일치). 1회차 grok NOT PASS (S9) 를 이 커밋이 닫음.
- **본 체크아웃**: `main` `1debc7d` 유지.
`git diff --check 59be1c8...HEAD` 클린. 커밋 1개.
## (1) 세 정정 — 닫힘
**R1/H1.** `runner.py:472-491`: `prompt_file=""` 로 초기화하고, 이름을 쓰기 **전**에 잡은 뒤 `fh.write`. `OSError` 면 부분 파일을 지우고 rc 126 `CompletedProcess` 를 반환한다. 그 반환은 `subprocess.run` 의 try 에 들어가지 않으므로 래퍼는 실행되지 않고 unlink 는 한 번이다. 정상 경로는 아래 `finally` 가 한 번 지운다. rc 126 은 exec 실패와 같아 busy(5) 재시도가 아니고, stdout 에 job id 가 없으면 `None` → 루프 rc 1. S4c 통과. 임시 사본에서 이 except 를 빼면 S4c 가 `OSError: No space left on device` 로 실패했다.
**R2/N1.** S9 (`test_issue36_prompt_transport.py`) 는 rc≠0, `prompt-shloop-*` 없음, `cannot write the prompt file` 를 모든 플랫폼에서 보고, `File too large` 는 `sys.platform == "linux"` 일 때만. 이 macOS 에서 S9 포함 **23 passed, 1 skipped**.
**H3.** 래퍼 `multi-agent-mux-delegate-job:193` `[[ -s "$PROMPT_FILE" ]]` 아니면 exit 1, `is empty`. S1e 통과.
## (2) 빈 파일 거부
막힌 것은 내용이 없는 `--prompt-file` 뿐이다. `--prompt` 짧은 문자열과 loop 형 `prompt.md`(본문을 쓴 뒤 넘김)은 그대로다. 빈 파일을 등록해야 하는 호출자는 없다.
## (3) 1회차·락스텝
파일 전달, #23 brief 경로, 순응 잡 rc 1 은 이 커밋이 되돌리지 않는다. 4.7.3 bump 는 오케스트레이터 예정. 병합 차단 아님.
## 테스트
검증 경로, `AGENT_PYTHON_BIN` = 컨트롤 `.venv`, basetemp `/tmp`:
`tests/test_issue36_prompt_transport.py` → **23 passed, 1 skipped in 38.74s** (skip 은 Linux `E2BIG` S1b).
## 종합
1회차 N1 과 쓰기 실패·빈 파일 지적이 코드·macOS 실행과 맞다. 설계 재작업 불필요.
[VERDICT: PASS]
+23 -5
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep # Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py. # with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable. # NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.7.2" MAM_VERSION="4.7.3"
export MAM_VERSION export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -2032,22 +2032,36 @@ _delegate_script() {
printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)" printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)"
} }
# _delegate_prompt_file <prompt> — write the prompt to a new file under the
# control root's .mam/ and echo its path. Issue #36: a prompt handed to an exec
# as one argv string is capped at 131,072 bytes; the caller removes the file.
_delegate_prompt_file() {
local dir="${WORKSPACE_ROOT:-$PWD}/.mam" f
mkdir -p "$dir" || return 1
f="$(mktemp "$dir/prompt-submit-XXXXXX")" || return 1
printf '%s' "$1" > "$f" || { rm -f "$f"; return 1; }
printf '%s\n' "$f"
}
# delegate_submit_job <prompt> <agent> <agent_session> # delegate_submit_job <prompt> <agent> <agent_session>
# #
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout. # Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
_delegate_submit_job_legacy() { _delegate_submit_job_legacy() {
local prompt="$1" agent="$2" session="$3" local prompt="$1" agent="$2" session="$3"
local py_bin registry_py local py_bin registry_py prompt_file rc=0
py_bin="$(_delegate_py_bin)" py_bin="$(_delegate_py_bin)"
registry_py="$(_delegate_script registry.py)" registry_py="$(_delegate_script registry.py)"
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2 echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
return 1 return 1
fi fi
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
"$py_bin" "$registry_py" register \ "$py_bin" "$registry_py" register \
--prompt "$prompt" \ --prompt-file "$prompt_file" \
--agent "$agent" \ --agent "$agent" \
--agent-session "$session" --agent-session "$session" || rc=$?
rm -f "$prompt_file"
return $rc
} }
# delegate_publish_event <job_id> <event> [detail] # delegate_publish_event <job_id> <event> [detail]
@@ -2152,7 +2166,11 @@ delegate_submit_job() {
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2 echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
return 1 return 1
fi fi
_delegation_py submit --prompt "$prompt" --agent "$agent" --agent-session "$session" local prompt_file rc=0
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
_delegation_py submit --prompt-file "$prompt_file" --agent "$agent" --agent-session "$session" || rc=$?
rm -f "$prompt_file"
return $rc
} }
# delegate_publish_event <job_id> <event> [detail] # delegate_publish_event <job_id> <event> [detail]
+8 -2
View File
@@ -26,7 +26,11 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
p_submit = sub.add_parser("submit", help="register a job; prints the job id") p_submit = sub.add_parser("submit", help="register a job; prints the job id")
p_submit.add_argument("--prompt", required=True) # Issue #36: lib.sh passes the prompt by file (one argv string is capped
# at 131,072 bytes); --prompt keeps working.
submit_prompt = p_submit.add_mutually_exclusive_group(required=True)
submit_prompt.add_argument("--prompt")
submit_prompt.add_argument("--prompt-file")
p_submit.add_argument("--agent", default="claude-code") p_submit.add_argument("--agent", default="claude-code")
p_submit.add_argument("--agent-session", default="herdr:claude") p_submit.add_argument("--agent-session", default="herdr:claude")
p_submit.add_argument("--registry-dir", default=None) p_submit.add_argument("--registry-dir", default=None)
@@ -57,7 +61,9 @@ def main(argv: Optional[List[str]] = None) -> int:
args = _build_parser().parse_args(argv) args = _build_parser().parse_args(argv)
if args.command == "submit": if args.command == "submit":
forward = ["register", "--prompt", args.prompt, prompt_arg = (["--prompt-file", args.prompt_file] if args.prompt_file is not None
else ["--prompt", args.prompt])
forward = ["register", *prompt_arg,
"--agent", args.agent, "--agent-session", args.agent_session] "--agent", args.agent, "--agent-session", args.agent_session]
if args.registry_dir: if args.registry_dir:
forward = ["--registry-dir", args.registry_dir] + forward forward = ["--registry-dir", args.registry_dir] + forward
+24 -6
View File
@@ -323,7 +323,11 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
p_reg = sub.add_parser("register", help="create a pending job; prints the job id") p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
p_reg.add_argument("--prompt", required=True) # Issue #36: a long prompt goes by file - one argv string is capped at
# MAX_ARG_STRLEN (131,072 bytes on Linux).
reg_prompt = p_reg.add_mutually_exclusive_group(required=True)
reg_prompt.add_argument("--prompt")
reg_prompt.add_argument("--prompt-file", help="read the prompt from this file (utf-8)")
p_reg.add_argument("--agent", default="claude-code") p_reg.add_argument("--agent", default="claude-code")
p_reg.add_argument("--agent-session", default="herdr:claude") p_reg.add_argument("--agent-session", default="herdr:claude")
p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)") p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)")
@@ -365,7 +369,9 @@ def _build_parser() -> argparse.ArgumentParser:
p_update.add_argument("--job", required=True) p_update.add_argument("--job", required=True)
p_update.add_argument("--status", default=None) p_update.add_argument("--status", default=None)
p_update.add_argument("--agent-session", default=None) p_update.add_argument("--agent-session", default=None)
p_update.add_argument("--prompt", default=None) upd_prompt = p_update.add_mutually_exclusive_group()
upd_prompt.add_argument("--prompt", default=None)
upd_prompt.add_argument("--prompt-file", default=None, help="read the prompt from this file (utf-8)")
p_update.add_argument("--iteration", type=int, default=None) p_update.add_argument("--iteration", type=int, default=None)
p_update.add_argument("--attempt-id", type=int, default=None) p_update.add_argument("--attempt-id", type=int, default=None)
p_update.add_argument("--role", default=None) p_update.add_argument("--role", default=None)
@@ -473,15 +479,26 @@ def _operator_write(job_id: str, registry_dir: str, **fields) -> int:
return 4 return 4
def _prompt_from_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> Optional[str]:
"""The prompt text: from --prompt-file (read verbatim) or from --prompt."""
if getattr(args, "prompt_file", None) is None:
return args.prompt
try:
return Path(args.prompt_file).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
parser.error(f"cannot read --prompt-file {args.prompt_file}: {exc}")
def main(argv: Optional[List[str]] = None) -> int: def main(argv: Optional[List[str]] = None) -> int:
mqtt_common.ensure_env_loaded() mqtt_common.ensure_env_loaded()
mqtt_common.setup_logging(logging.INFO) mqtt_common.setup_logging(logging.INFO)
args = _build_parser().parse_args(argv) parser = _build_parser()
args = parser.parse_args(argv)
rd = args.registry_dir rd = args.registry_dir
if args.command == "register": if args.command == "register":
job_id = register_job( job_id = register_job(
prompt=args.prompt, prompt=_prompt_from_args(parser, args),
agent=args.agent, agent=args.agent,
agent_session=args.agent_session, agent_session=args.agent_session,
role=args.role, role=args.role,
@@ -540,8 +557,9 @@ def main(argv: Optional[List[str]] = None) -> int:
fields["status"] = args.status fields["status"] = args.status
if args.agent_session is not None: if args.agent_session is not None:
fields["agent_session"] = args.agent_session fields["agent_session"] = args.agent_session
if args.prompt is not None: prompt = _prompt_from_args(parser, args)
fields["prompt"] = args.prompt if prompt is not None:
fields["prompt"] = prompt
if args.iteration is not None: if args.iteration is not None:
fields["iteration"] = args.iteration fields["iteration"] = args.iteration
if args.attempt_id is not None: if args.attempt_id is not None:
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence from typing import List, Sequence
# Framework version locked with framework constants # Framework version locked with framework constants
FRAMEWORK_VERSION = "4.7.2" FRAMEWORK_VERSION = "4.7.3"
CORE_MODULES: Sequence[str] = ( CORE_MODULES: Sequence[str] = (
# Standard library modules # Standard library modules
+51 -6
View File
@@ -464,7 +464,31 @@ class LoopRunner:
if not wrapper_script.exists(): if not wrapper_script.exists():
wrapper_script = Path(__file__).resolve().parent.parent / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" wrapper_script = Path(__file__).resolve().parent.parent / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
def _run_cmd(p_text: str): def _run_cmd(p_text: str) -> subprocess.CompletedProcess:
# Issue #36: the prompt goes by file. One argv string is capped at
# MAX_ARG_STRLEN (131,072 bytes on Linux), and a review prompt
# carries the whole diff. The wrapper blocks until the job ends,
# so nothing reads the file after it returns.
prompt_file = ""
try:
self.mam_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=self.mam_dir,
prefix=f"prompt-{self.loop_id}-", suffix=".md",
delete=False) as fh:
prompt_file = fh.name
fh.write(p_text)
except OSError as e:
# The same class the shell paths handle (merge review H1): a
# partial file is removed and the submit fails like an exec
# error would, instead of the exception reaching the loop.
if prompt_file:
try:
os.unlink(prompt_file)
except OSError:
pass
msg = (f"cannot write the prompt file under {self.mam_dir}: {e} "
f"(prompt {len(p_text.encode('utf-8'))} bytes)")
return subprocess.CompletedProcess(["bash", str(wrapper_script), "submit"], 126, "", msg)
cmd = [ cmd = [
"bash", str(wrapper_script), "submit", "bash", str(wrapper_script), "submit",
"--agent-session", self.session_label(agent_session), "--agent-session", self.session_label(agent_session),
@@ -472,12 +496,24 @@ class LoopRunner:
"--type", "direct", "--type", "direct",
"--role", role, "--role", role,
"--registry-dir", str(self.jobs_dir), "--registry-dir", str(self.jobs_dir),
"--prompt", p_text, "--prompt-file", prompt_file,
] + self.contract_args() ] + self.contract_args()
env = dict(os.environ) env = dict(os.environ)
if "DELEGATE_JOB_PYTHON" not in env: if "DELEGATE_JOB_PYTHON" not in env:
env["DELEGATE_JOB_PYTHON"] = sys.executable env["DELEGATE_JOB_PYTHON"] = sys.executable
try:
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env) return subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
except OSError as e:
# A wrapper that cannot start is a failed submit (126, the shell's
# "cannot execute"), on the first try and on the retry alike.
msg = (f"cannot start the wrapper: {e} "
f"(prompt {len(p_text.encode('utf-8'))} bytes, wrapper {wrapper_script})")
return subprocess.CompletedProcess(cmd, 126, "", msg)
finally:
try:
os.unlink(prompt_file)
except OSError:
pass
res = _run_cmd(prompt) res = _run_cmd(prompt)
match = re.search(r"registered job:\s*([A-Za-z0-9_-]+)", res.stdout) match = re.search(r"registered job:\s*([A-Za-z0-9_-]+)", res.stdout)
@@ -500,6 +536,7 @@ class LoopRunner:
self.failed_submit_jobs.add(job_id_retry) self.failed_submit_jobs.add(job_id_retry)
log_error(f"delegate_job retry failed (exit {res_retry.returncode}):\n{res_retry.stderr or res_retry.stdout}") log_error(f"delegate_job retry failed (exit {res_retry.returncode}):\n{res_retry.stderr or res_retry.stdout}")
return job_id_retry return job_id_retry
log_error(f"delegate_job retry failed (exit {res_retry.returncode}):\n{res_retry.stderr or res_retry.stdout}")
if job_id: if job_id:
self.created_jobs.append(job_id) self.created_jobs.append(job_id)
self.failed_submit_jobs.add(job_id) self.failed_submit_jobs.add(job_id)
@@ -1327,7 +1364,11 @@ class LoopRunner:
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}" adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt, adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt,
remote=rt in self._remote_recipients, purpose="adjudication") remote=rt in self._remote_recipients, purpose="adjudication")
if not adj_jid or not self.wait_for_job(adj_jid): if not adj_jid:
# a job that was never registered is a failure, not a verdict (Issue #36)
log_error(f"Failed to register re-adjudication job for '{rt}'.")
return 1
if not self.wait_for_job(adj_jid):
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.") log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
overruled_revs.append(rt) overruled_revs.append(rt)
continue continue
@@ -1346,7 +1387,10 @@ class LoopRunner:
p_type = self.resolve_agent_type(planner_session, state) p_type = self.resolve_agent_type(planner_session, state)
arb_prompt = f"작업자(Creator)와 리뷰어의 의견이 충돌하여 교착 상태입니다. 계획서 관점에서 어느 쪽이 타당한지 재정(arbitration)하십시오. 리포트 마지막에 단독 행으로 '[ARBITRATION: CREATOR]' 또는 '[ARBITRATION: REVIEWER]' 를 명시하십시오. 리뷰어 지적:\n{feedback_aggregate}\n작업자 반론:\n{rebuttal_text}\n기존 계획서:\n{self.current_plan}" arb_prompt = f"작업자(Creator)와 리뷰어의 의견이 충돌하여 교착 상태입니다. 계획서 관점에서 어느 쪽이 타당한지 재정(arbitration)하십시오. 리포트 마지막에 단독 행으로 '[ARBITRATION: CREATOR]' 또는 '[ARBITRATION: REVIEWER]' 를 명시하십시오. 리뷰어 지적:\n{feedback_aggregate}\n작업자 반론:\n{rebuttal_text}\n기존 계획서:\n{self.current_plan}"
arb_jid = self._dispatch(planner_session, p_type, "Planner", arb_prompt) arb_jid = self._dispatch(planner_session, p_type, "Planner", arb_prompt)
if arb_jid and self.wait_for_job(arb_jid): if not arb_jid:
log_error("Failed to register Planner arbitration job.")
return 1
if self.wait_for_job(arb_jid):
arb_file = self.find_report(arb_jid) arb_file = self.find_report(arb_jid)
if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"): if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"):
arbitration = "CREATOR" arbitration = "CREATOR"
@@ -1360,8 +1404,9 @@ class LoopRunner:
rebut_budget = 0 rebut_budget = 0
comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}" comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}"
comp_jid = self._dispatch(self.creator, creator_type, "Worker", comply_prompt) comp_jid = self._dispatch(self.creator, creator_type, "Worker", comply_prompt)
if comp_jid: if not comp_jid or not self.wait_for_job(comp_jid):
self.wait_for_job(comp_jid) log_error("Failed to register Creator compliance job.")
return 1
# Promotion # Promotion
if self.plan_mode and self.current_plan and self.plan_job_id: if self.plan_mode and self.current_plan and self.plan_job_id:
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-create name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace." description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -622,6 +622,19 @@ if [ -n "$DELEGATE_JOB_ID" ]; then
# dispatch (trial-2 finding: without them the token-checking ack below could # dispatch (trial-2 finding: without them the token-checking ack below could
# never be satisfied by a real agent, and every onboarding sat out 180 s). # never be satisfied by a real agent, and every onboarding sat out 180 s).
pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")" pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"
# Issue #36: the task goes into a brief in the job's directory and the pane
# gets only its path, as the delegate-job wrapper does. The injected text is
# one argv string to herdr (capped at 131,072 bytes), so the whole task
# plus this preamble could not reach the agent.
task_reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
[[ "$task_reg_dir" == /* ]] || task_reg_dir="$PWD/$task_reg_dir"
task_brief="$task_reg_dir/$DELEGATE_JOB_ID/brief.md"
if ! { mkdir -p "${task_brief%/*}" && printf '%s' "$SUBMIT_JOB_PROMPT" > "$task_brief"; }; then
echo "ERROR: cannot write the task brief $task_brief" >&2
delegate_publish_event "$DELEGATE_JOB_ID" error "task brief could not be written" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
JOB_TERMINALIZED=1
exit 1
fi
instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation). instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation).
On start run: $pub --event started. On start run: $pub --event started.
@@ -629,7 +642,7 @@ On progress (optional): $pub --event progress --detail '<short status>'.
On success run: $pub --event completed --detail '<one-line summary>'. On success run: $pub --event completed --detail '<one-line summary>'.
On failure run: $pub --event error --detail '<one-line reason>'. On failure run: $pub --event error --detail '<one-line reason>'.
Task: $SUBMIT_JOB_PROMPT" Task: read the complete task in $task_brief and follow it."
# Inject instructions into the herdr pane # Inject instructions into the herdr pane
rc=0 rc=0
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-delegate-job name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer." description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos, windows] platforms: [linux, macos, windows]
@@ -44,6 +44,8 @@ multi-agent-mux-delegate-job submit \
# Liveness contract (Issue #18): the agent heartbeats every N s (automatic), # Liveness contract (Issue #18): the agent heartbeats every N s (automatic),
# the job is unresponsive after K x N s (--idle-timeout overrides), `started` # the job is unresponsive after K x N s (--idle-timeout overrides), `started`
# must arrive within T s of the injection or the inject fails closed. # must arrive within T s of the injection or the inject fails closed.
# --prompt-file <path> replaces --prompt for long prompts (Issue #36): one
# argv string is capped at 131,072 bytes on Linux. The loop always uses it.
# 1b) Same, for an agent on another host (no herdr pane, no shared disk): # 1b) Same, for an agent on another host (no herdr pane, no shared disk):
# the brief travels over the broker as a signed retained dispatch and a # the brief travels over the broker as a signed retained dispatch and a
@@ -109,13 +109,15 @@ usage() {
cat <<'EOF' cat <<'EOF'
multi-agent-mux-delegate-job <command> [options] multi-agent-mux-delegate-job <command> [options]
submit --agent <name> --prompt <text> [--workdir <dir>] [--agent-session <label>] submit --agent <name> (--prompt <text> | --prompt-file <path>) [--workdir <dir>] [--agent-session <label>]
[--timeout <sec>] [--idle-timeout <sec>] [--validate <script>] [--timeout <sec>] [--idle-timeout <sec>] [--validate <script>]
[--heartbeat <sec>] [--heartbeat-misses <count>] [--ack-timeout <sec>] [--heartbeat <sec>] [--heartbeat-misses <count>] [--ack-timeout <sec>]
[--registry-dir <dir>] [--dry-run] [--role <role_name>] [--registry-dir <dir>] [--dry-run] [--role <role_name>]
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>] [--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
[--reviewer-session <reviewer_session>] [--max-iterations <count>] [--reviewer-session <reviewer_session>] [--max-iterations <count>]
[--counterpart-role <role_name>] [--strict-role-check] [--counterpart-role <role_name>] [--strict-role-check]
# --prompt-file passes the prompt by file: one argv string is capped
# at 131,072 bytes on Linux (Issue #36). The file is read, not kept.
# --agent-session herdr:<name> injects into a local herdr pane; # --agent-session herdr:<name> injects into a local herdr pane;
# --agent-session remote:<name> dispatches over the broker (Issue #18). # --agent-session remote:<name> dispatches over the broker (Issue #18).
# Liveness contract: the agent heartbeats every --heartbeat N s # Liveness contract: the agent heartbeats every --heartbeat N s
@@ -131,7 +133,7 @@ EOF
} }
# ---- arg parsing helpers -------------------------------------------------- # ---- arg parsing helpers --------------------------------------------------
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude" AGENT="claude-code"; PROMPT=""; PROMPT_FILE=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude"
TIMEOUT=3600; IDLE_TIMEOUT=""; VALIDATE=""; DRY_RUN=0 TIMEOUT=3600; IDLE_TIMEOUT=""; VALIDATE=""; DRY_RUN=0
# Issue #18 liveness contract (N / K / T). An unset --idle-timeout derives K x N. # Issue #18 liveness contract (N / K / T). An unset --idle-timeout derives K x N.
HEARTBEAT_SEC="${MAM_HEARTBEAT_SEC:-30}"; HEARTBEAT_MISSES="${MAM_HEARTBEAT_MISSES:-4}"; ACK_TIMEOUT="${MAM_ACK_TIMEOUT_S:-180}" HEARTBEAT_SEC="${MAM_HEARTBEAT_SEC:-30}"; HEARTBEAT_MISSES="${MAM_HEARTBEAT_MISSES:-4}"; ACK_TIMEOUT="${MAM_ACK_TIMEOUT_S:-180}"
@@ -148,6 +150,7 @@ parse_opts() {
case "$1" in case "$1" in
--agent) AGENT="$2"; shift 2;; --agent) AGENT="$2"; shift 2;;
--prompt) PROMPT="$2"; shift 2;; --prompt) PROMPT="$2"; shift 2;;
--prompt-file) PROMPT_FILE="$2"; shift 2;;
--workdir) WORKDIR="$2"; shift 2;; --workdir) WORKDIR="$2"; shift 2;;
--agent-session) AGENT_SESSION="$2"; shift 2;; --agent-session) AGENT_SESSION="$2"; shift 2;;
--timeout) TIMEOUT="$2"; shift 2;; --timeout) TIMEOUT="$2"; shift 2;;
@@ -183,7 +186,18 @@ _to_lower() {
cmd_submit() { cmd_submit() {
parse_opts "$@" parse_opts "$@"
[[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; } if [[ -n "$PROMPT_FILE" ]]; then
[[ -z "$PROMPT" ]] || { echo "submit takes --prompt or --prompt-file, not both" >&2; exit 1; }
[[ "$PROMPT_FILE" == /* ]] || PROMPT_FILE="$(pwd)/$PROMPT_FILE"
[[ -f "$PROMPT_FILE" && -r "$PROMPT_FILE" ]] || { echo "submit: cannot read --prompt-file $PROMPT_FILE" >&2; exit 1; }
[[ -s "$PROMPT_FILE" ]] || { echo "submit: --prompt-file $PROMPT_FILE is empty" >&2; exit 1; }
PROMPT="$(<"$PROMPT_FILE")"
fi
[[ -n "$PROMPT" || -n "$PROMPT_FILE" ]] || { echo "submit requires --prompt or --prompt-file" >&2; exit 1; }
# Issue #36: the prompt reaches the registry by file when it came by file,
# never as one argv string (capped at 131,072 bytes).
local reg_prompt=(--prompt "$PROMPT")
[[ -n "$PROMPT_FILE" ]] && reg_prompt=(--prompt-file "$PROMPT_FILE")
PY="$(pick_python)" PY="$(pick_python)"
[[ "$PY" == */* && "$PY" != /* ]] && PY="$(pwd)/$PY" [[ "$PY" == */* && "$PY" != /* ]] && PY="$(pwd)/$PY"
cd "$WORKDIR" cd "$WORKDIR"
@@ -192,7 +206,7 @@ cmd_submit() {
# 1) register job (prints the new job id) # 1) register job (prints the new job id)
JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \
--prompt "$PROMPT" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \ "${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \ --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
--heartbeat-sec "$HEARTBEAT_SEC" --heartbeat-misses "$HEARTBEAT_MISSES" --ack-timeout "$ACK_TIMEOUT" \ --heartbeat-sec "$HEARTBEAT_SEC" --heartbeat-misses "$HEARTBEAT_MISSES" --ack-timeout "$ACK_TIMEOUT" \
--transport "$(_is_remote "$AGENT_SESSION" && echo remote || echo herdr)" \ --transport "$(_is_remote "$AGENT_SESSION" && echo remote || echo herdr)" \
@@ -392,6 +406,8 @@ EOF
## 🔎 Task Description ## 🔎 Task Description
$current_prompt $current_prompt
EOF EOF
# Issue #36: the registry gets the prompt by file, not as one argv string
printf '%s' "$current_prompt" > "$job_dir/prompt.md"
echo "provisioned iteration brief: $job_dir/brief.md" echo "provisioned iteration brief: $job_dir/brief.md"
attempt=$((attempt + 1)) attempt=$((attempt + 1))
@@ -432,7 +448,7 @@ EOF
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \ "$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
--job "$JOB_ID" \ --job "$JOB_ID" \
--agent-session "$current_session" \ --agent-session "$current_session" \
--prompt "$current_prompt" \ --prompt-file "$job_dir/prompt.md" \
--iteration "$iteration" \ --iteration "$iteration" \
--attempt-id "$attempt" \ --attempt-id "$attempt" \
--role "$display_role" \ --role "$display_role" \
+2 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-loop name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached." description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -264,4 +264,5 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다. - **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive` 및 `running`) 있어야 합니다. - **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive` 및 `running`) 있어야 합니다.
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오. (실제로는 loop lock 이 두 번째 루프를 exit 1 로 거부한다 — run_loop.sh 의 `mam_acquire_loop_lock` 분기) - **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오. (실제로는 loop lock 이 두 번째 루프를 exit 1 로 거부한다 — run_loop.sh 의 `mam_acquire_loop_lock` 분기)
- **파이프라인의 종료 코드 (Issue #36)**: 위임이 실패하면 루프는 원인을 stderr 에 남기고 0 이 아닌 코드로 끝납니다. 루프를 `… | tee log` 처럼 파이프라인으로 돌리면 `$?` 는 `tee` 의 것이므로 `set -o pipefail` 또는 `${PIPESTATUS[0]}` 로 루프의 코드를 보십시오. stderr 도 함께 남기십시오(`2>&1`).
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다. - **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
@@ -6,6 +6,9 @@ MAM_DIFF_UNAVAILABLE_MARKER="!!! CHANGE SET UNAVAILABLE !!!"
MAM_DIFF_TRUNCATED_MARKER="!!! DIFF TRUNCATED !!!" MAM_DIFF_TRUNCATED_MARKER="!!! DIFF TRUNCATED !!!"
MAM_DIFF_NESTED_MARKER="!!! NOT EXPANDED IN THIS DIFF !!!" MAM_DIFF_NESTED_MARKER="!!! NOT EXPANDED IN THIS DIFF !!!"
# These caps bound the evidence a reviewer is shown, nothing else. The prompt
# reaches the agent by file (Issue #36), so delivery has no size cap of its own:
# only a diff over these limits is truncated (and marked).
MAM_DIFF_MAX_BYTES="${MAM_DIFF_MAX_BYTES:-200000}" MAM_DIFF_MAX_BYTES="${MAM_DIFF_MAX_BYTES:-200000}"
MAM_DIFF_MAX_LINES="${MAM_DIFF_MAX_LINES:-4000}" MAM_DIFF_MAX_LINES="${MAM_DIFF_MAX_LINES:-4000}"
@@ -187,14 +187,37 @@ _mam_release_guard() {
# Runs the delegate-job wrapper from the frozen skills tree (REPO_ROOT). # Runs the delegate-job wrapper from the frozen skills tree (REPO_ROOT).
# Stage 2 creates a single snapshot at loop initialization outside the skill tree (B-13), # Stage 2 creates a single snapshot at loop initialization outside the skill tree (B-13),
# keeping the running loop immune across turns without per-call copies or tree pollution (B-6). # keeping the running loop immune across turns without per-call copies or tree pollution (B-6).
#
# Issue #36: a `--prompt <text>` argument is handed to the wrapper as
# `--prompt-file <path>` instead - one argv string is capped at 131,072 bytes
# and a review prompt carries the whole diff. The file lives under the control
# root's .mam/ and is removed once the wrapper returns (it blocks until the
# job ends, so nothing reads the file afterwards).
delegate_job_safe() { delegate_job_safe() {
local wrapper_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job" local wrapper_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
local rc=0 local rc=0 prompt_file="" args=()
bash "$wrapper_script" "$@" || rc=$? while [ $# -gt 0 ]; do
if [ "$1" = "--prompt" ] && [ $# -ge 2 ]; then
mkdir -p "$MAM_REAL_ROOT/.mam" \
&& prompt_file="$(mktemp "$MAM_REAL_ROOT/.mam/prompt-shloop-XXXXXX")" \
&& printf '%s' "$2" > "$prompt_file" \
|| { [ -n "$prompt_file" ] && rm -f "$prompt_file"
log_error "delegate_job_safe: cannot write the prompt file under $MAM_REAL_ROOT/.mam" >&2; return 1; }
args+=(--prompt-file "$prompt_file")
shift 2
else
args+=("$1")
shift
fi
done
bash "$wrapper_script" ${args[@]+"${args[@]}"} || rc=$?
[ -n "$prompt_file" ] && rm -f "$prompt_file"
if [ "$rc" -ne 0 ]; then if [ "$rc" -ne 0 ]; then
log_error "delegate_job_safe failed (exit $rc): $wrapper_script" # stderr: callers capture stdout with $(...), and under set -e a failed
log_error " if this loop edits framework skills in place, check that file's syntax:" # submit ends the loop right there - this line is the only cause shown.
log_error " bash -n \"$wrapper_script\"" log_error "delegate_job_safe failed (exit $rc): $wrapper_script" >&2
log_error " if this loop edits framework skills in place, check that file's syntax:" >&2
log_error " bash -n \"$wrapper_script\"" >&2
fi fi
return $rc return $rc
} }
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-monitor name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted." description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-orc-onboard name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture." description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-resume name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk." description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-status name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop." description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: multi-agent-mux-stop name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start." description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.7.2 version: 4.7.3
author: godopu author: godopu
license: MIT license: MIT
platforms: [linux, macos] platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다. 본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) [![Version: v4.7.3](https://img.shields.io/badge/version-v4.7.3-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다. 새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly. This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) [![Version: v4.7.3](https://img.shields.io/badge/version-v4.7.3-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment. A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report # Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.7.2 Applies to: MAM v4.7.3
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations. This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+1 -1
View File
@@ -2,7 +2,7 @@
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다. Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) [![Version: v4.7.3](https://img.shields.io/badge/version-v4.7.3-blue.svg)](VERSIONS.md)
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`). An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
[![Version: v4.7.2](https://img.shields.io/badge/version-v4.7.2-blue.svg)](VERSIONS.md) [![Version: v4.7.3](https://img.shields.io/badge/version-v4.7.3-blue.svg)](VERSIONS.md)
--- ---
+27 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release) ## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.7.2` - **프레임워크 버전**: `v4.7.3`
- **최신 릴리스 일시**: 2026-09-23 (KST) - **최신 릴리스 일시**: 2026-09-24 (KST)
- **기준 브랜치**: `main` - **기준 브랜치**: `main`
- **핵심 아키텍처**: - **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입. - **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -22,23 +22,39 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix) ## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.2`로 동기화되어 배포됩니다. 모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.3`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 | | 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: | | :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.7.2` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 | | **`multi-agent-mux-create`** | `4.7.3` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.7.2` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 | | **`multi-agent-mux-stop`** | `4.7.3` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.7.2` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 | | **`multi-agent-mux-resume`** | `4.7.3` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.7.2` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 | | **`multi-agent-mux-status`** | `4.7.3` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.7.2` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 | | **`multi-agent-mux-monitor`** | `4.7.3` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.7.2` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 | | **`multi-agent-mux-delegate-job`** | `4.7.3` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.7.2` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 | | **`multi-agent-mux-loop`** | `4.7.3` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.7.2` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 | | **`multi-agent-mux-orc-onboard`** | `4.7.3` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
--- ---
## 📋 버전별 상세 변경 내역 (Changelog) ## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.7.3` — Issue #36 위임 프롬프트를 argv 가 아닌 파일로 전달, 위임 실패는 종료 코드 1 (2026-09-24, PR #37)
> **패치 마일스톤 (PATCH Release)**: 리뷰 루프가 diff 를 리뷰 프롬프트에 넣어 bash 에 **단일 argv 원소**로 넘기던 탓에, 프롬프트가 Linux 의 단일 인자 상한(`MAX_ARG_STRLEN` 131,072 바이트)을 넘으면 `OSError: [Errno 7]` 로 죽고 **종료 코드 0** 으로 끝나던 문제(Issue #36)를 고쳤다. 수집기 상한 `MAM_DIFF_MAX_BYTES`(기본 200000)가 커널 상한보다 높아 그 사이 크기는 걸러지지 않았고, 커밋 10여 개 규모의 브랜치(PR #35 가 실제로 133 KB)는 루프로 리뷰할 수 없었다. 옵션 하나(`--prompt-file`)가 늘지만 잘못된 전달 경로를 고치는 수정이라 PATCH 로 올린다.
#### 🌟 주요 개선 사항
1. **프롬프트는 파일로 전달 (`lib_py/loop/runner.py`, delegate-job 래퍼, `lib_py/delegation/registry.py`·`cli.py`, `lib.sh`, `run_loop.sh`)**: Python 러너는 모든 위임 프롬프트를 제어 루트 `.mam/` 아래 임시 파일에 쓰고 래퍼에 `--prompt-file` 로 넘긴 뒤 래퍼가 끝나면 지운다. 래퍼와 등록부는 `--prompt`/`--prompt-file` 을 상호 배타로 받고, 래퍼는 받은 경로를 등록부에 그대로 넘기며 loop 형 반복의 `update` 도 `prompt.md` 파일로 넘긴다. `lib.sh` 의 제출 도우미(`delegate_submit_job` 계열)와 `cli.py` 사슬, 옛 셸 러너(`MAM_LOOP_PY=0`)의 위임 11곳이 거치는 `delegate_job_safe` 도 파일로 넘긴다(쓰기 실패 시 부분 파일을 지우고 stderr 로 진단). `create_session.sh` 는 주입 안내문에 과제 본문 대신 잡 디렉터리의 `brief.md` 절대경로만 넣는다(herdr 주입문도 argv 하나다). 기존 `--prompt` 는 그대로 받는다.
2. **위임 실패는 성공처럼 끝나지 않음 (`runner.py`)**: 래퍼를 실행할 수 없으면(OSError) rc 126 의 실패한 제출로 다루고 첫 시도·재시도 모두 원인을 남기며 루프는 rc 1 로 끝난다. 반론 이후의 재심·중재 잡이 등록되지 않은 경우와 순응 잡이 등록되지 않거나 끝나지 않은 경우도 판정이 아니라 실패(rc 1)다.
3. **수집기 상한의 의미 (`diff_collect.sh`)**: `MAM_DIFF_MAX_BYTES`·`MAM_DIFF_MAX_LINES` 는 리뷰어에게 보여줄 증거의 크기만 제한한다. 전달 자체에는 크기 한계가 없고, 잘린 diff 를 증거로 쓸 때의 기존 규칙(diff-only 원격 리뷰어 거부)은 그대로다.
4. **문서**: delegate-job SKILL.md(`--prompt-file`), loop SKILL.md.
#### ✅ 품질 검증
- **신규 테스트 22개** (`tests/test_issue36_prompt_transport.py`): 131,072 바이트를 넘는 실제 프롬프트가 실제 subprocess/exec 경로(러너 → 래퍼 → 등록부, `lib.sh` 도우미, 옛 셸 러너, `create_session`)를 지나는지, 임시 파일의 생성·정리·쓰기 실패 처리, 래퍼를 실행할 수 없을 때 rc 1 과 트레이스백 부재. 수정 전 코드에서는 13건 중 11건이 실패.
- **리뷰**: 작성 워크스페이스 5인 3라운드 전원 PASS(codex 1차: 반론 경로에서 잡 등록 실패 시 루프 계속 → 14d11bf; 오케스트레이터: 옛 셸 러너·`lib.sh` 도우미의 argv 잔존 → f26952c; codex 델타: strace 로 `create_session` 의 E2BIG 와 셸 러너의 부분 파일 잔존 재현 → 59be1c8). 병합 워크스페이스에서 PR head 를 detached 워크트리에 받아 재검토: 1회차 @59be1c8 claude PASS(비차단 H1–H5), agy·grok·codex NOT PASS — 새 테스트 S9 가 stderr 의 리터럴 `File too large` 를 요구해 macOS bash 3.2 에서 실패(agy·grok·codex R2), Python 러너의 프롬프트 파일 **쓰기** 실패가 정리·오류 처리 범위 밖이라 부분 파일이 남고 예외가 루프까지 올라옴(claude H1·codex R1), 래퍼가 빈 `--prompt-file` 을 받아들임(claude H3) → 708262a 로 정정(쓰기 실패는 exec 실패와 같은 rc 126 실패 제출, libc 문구는 Linux 에서만 단언, 빈 파일 거부; 회귀 S4c·S1e 는 정정을 되돌리면 실패). 델타 리뷰 @708262a claude·agy·grok·codex **4인 전원 PASS**(identity 4건 모두 base 59be1c8 → head 708262a, evidence review-tree). codex 는 1회차에서 이 호스트의 `SC_ARG_MAX` 를 넘는 1,114,112 바이트 프롬프트가 argv 로는 `OSError(errno=7)` 이고 파일로는 공백 경로·다른 `--workdir` 조합에서도 원문 그대로 등록됨을 실측. 리포트와 identity 사이드카는 `.agents/reports/<session>/`.
- **전체 스위트 (병합 워크스페이스, 자체 venv 가 있는 워크트리 @59be1c8)**: @59be1c8 1139 통과 / 2 skip / 실패 1(S9, 위 R2; 54분 7초) → @708262a 관련 스위트(transport·delegation 계약·loop CLI·delivery ack·version) 125 통과 / 1 skip(Linux 전용 S1b).
- **비차단 후속**: `MAM_DIFF_MAX_*` 의 "증거 상한, 전달 무제한" 의미가 loop SKILL.md 에 없음(H2); 래퍼의 `$(<file)` 이 후행 개행을 잃어 등록부 프롬프트와 브리프 본문이 그 점에서 다를 수 있음(H4); 순응 잡의 실행 실패도 rc 1 로 끝내는 강화에서 로그 문구가 "Failed to register" 로 등록 실패와 구분되지 않음(grok·codex).
### 🚀 `v4.7.2` — Issue #23·#24 세션 생성 경로: 교차 서버 herdr 스코프, 에이전트 무관 시작 다이얼로그, 실제 에이전트가 충족하는 온보딩 전달 판정 (2026-09-23, PR #35) ### 🚀 `v4.7.2` — Issue #23·#24 세션 생성 경로: 교차 서버 herdr 스코프, 에이전트 무관 시작 다이얼로그, 실제 에이전트가 충족하는 온보딩 전달 판정 (2026-09-23, PR #35)
> **패치 마일스톤 (PATCH Release)**: `create_session.sh` 로 새 세션을 만드는 첫 1분 안의 결함 둘을 고쳤다. 오케스트레이터 pane 이 물려주는 herdr 환경(`HERDR_WORKSPACE_ID`·`HERDR_PANE_ID`·`HERDR_TAB_ID`)이 격리 서버 조회를 오염시켜 온보딩 프롬프트가 전달되지 않고, 전달 판정이 화면 기준이라 살아 있는 에이전트를 실패로 판정한 뒤 롤백까지 실패하던 문제(#23)와, 시작 신뢰(trust) 다이얼로그를 claude 에게만 처리해 agy·grok·codex 가 준비 대기 초과로 온보딩을 건너뛰던 문제(#24)다. 옵션 하나(`--no-auto-trust`)와 종료 코드 둘(42·43)이 늘지만 잘못된 기본 동작을 고치는 수정이라 PATCH 로 올린다. > **패치 마일스톤 (PATCH Release)**: `create_session.sh` 로 새 세션을 만드는 첫 1분 안의 결함 둘을 고쳤다. 오케스트레이터 pane 이 물려주는 herdr 환경(`HERDR_WORKSPACE_ID`·`HERDR_PANE_ID`·`HERDR_TAB_ID`)이 격리 서버 조회를 오염시켜 온보딩 프롬프트가 전달되지 않고, 전달 판정이 화면 기준이라 살아 있는 에이전트를 실패로 판정한 뒤 롤백까지 실패하던 문제(#23)와, 시작 신뢰(trust) 다이얼로그를 claude 에게만 처리해 agy·grok·codex 가 준비 대기 초과로 온보딩을 건너뛰던 문제(#24)다. 옵션 하나(`--no-auto-trust`)와 종료 코드 둘(42·43)이 늘지만 잘못된 기본 동작을 고치는 수정이라 PATCH 로 올린다.
+646
View File
@@ -0,0 +1,646 @@
"""Issue #36: a delegated prompt never travels as one argv string.
Linux caps a single argv (or env) string at MAX_ARG_STRLEN = 131,072 bytes,
independent of ARG_MAX. A review prompt carries the whole diff, and the diff
collector lets up to 200,000 bytes through, so a 131,072..200,000-byte diff
killed the loop with `OSError: [Errno 7] Argument list too long: 'bash'`.
The prompt now goes by file: runner -> wrapper (--prompt-file) -> registry
(--prompt-file). These tests push prompts over the cap through the real
bash wrapper, the real registry and a real `python -m lib_py.loop` process;
subprocess is replaced only where a test is about the error handling itself
(S4, S4b), never to prove the transport.
Every test writes only under tmp_path.
"""
from __future__ import annotations
import errno
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
DIFF_COLLECT = SKILLS / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
MAX_ARG_STRLEN = 131072
BIG = 140_000
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"}
GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
TRUNCATED = "!!! DIFF TRUNCATED !!!"
linux_only = pytest.mark.skipif(sys.platform != "linux", reason="MAX_ARG_STRLEN is a Linux constant")
def _env(home: Path, extra=None, drop=()) -> dict:
env = dict(os.environ)
env["DELEGATE_JOB_PYTHON"] = sys.executable
env["AGENT_PYTHON_BIN"] = sys.executable
env["HOME"] = str(home)
env["MAM_ENV_FILE"] = str(home / ".mam.env.absent")
env["PYTHONPATH"] = str(SKILLS)
for k in ("MQTT_BROKER", "MQTT_HOST", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
"MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT", "MAM_REAL_ROOT"):
env.pop(k, None)
env.update(UNREACHABLE)
env.update(GIT_ID)
# never this checkout's .mam (audit logs, herdr shim)
env["DELEGATE_JOB_LOGS_DIR"] = str(home / ".mam-logs")
env["WORKSPACE_ROOT"] = str(home)
if extra:
env.update({k: str(v) for k, v in extra.items()})
for k in drop:
env.pop(k, None)
return env
def _big_text(n: int = BIG) -> str:
"""ASCII with newlines, exactly n bytes."""
line = "x" * 63 + "\n"
return (line * (n // len(line) + 1))[:n]
def _dry_submit(cwd: Path, reg: Path, *prompt_args: str) -> subprocess.CompletedProcess:
return subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human",
"--agent-session", "herdr:x", "--dry-run",
"--registry-dir", str(reg), *prompt_args],
capture_output=True, text=True, env=_env(cwd), cwd=str(cwd), timeout=120)
def _registered_prompt(res: subprocess.CompletedProcess, reg: Path) -> str:
assert res.returncode == 0, res.stderr
m = re.search(r"registered job:\s*(\S+)", res.stdout)
assert m, res.stdout
return json.loads((reg / f"{m.group(1)}.json").read_text(encoding="utf-8"))["prompt"]
def _git(cwd: Path, *args: str) -> str:
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True,
check=True, env={**os.environ, **GIT_ID}).stdout.strip()
def _repo(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
_git(path, "init", "-q")
(path / "a.txt").write_text("a\n")
_git(path, "add", "a.txt")
_git(path, "commit", "-q", "-m", "init")
return path
def _fake_wrapper(root: Path, body: str) -> Path:
"""A wrapper at the control root, where the runner looks first."""
w = root / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
w.parent.mkdir(parents=True, exist_ok=True)
w.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8")
w.chmod(0o755)
return w
def _prompt_leftovers(root: Path) -> list:
return sorted(p.name for p in (root / ".mam").glob("prompt-*"))
# ---------------------------------------------------------------------------
# S1 - the real wrapper and registry take a prompt over the cap by file
# ---------------------------------------------------------------------------
def test_s1_wrapper_registers_a_prompt_over_the_cap_by_file(tmp_path):
text = _big_text()
pf = tmp_path / "prompt.md"
pf.write_text(text, encoding="utf-8")
reg = tmp_path / "reg"
res = _dry_submit(tmp_path, reg, "--prompt-file", str(pf))
assert _registered_prompt(res, reg) == text # byte for byte, trailing newline too
@linux_only
def test_s1b_the_same_prompt_as_an_argument_cannot_even_start(tmp_path):
"""Negative control: the failure S1 avoids is real, not a fixture artefact."""
with pytest.raises(OSError) as exc:
_dry_submit(tmp_path, tmp_path / "reg", "--prompt", _big_text())
assert exc.value.errno == errno.E2BIG
def test_s1c_prompt_still_works_and_the_two_options_exclude_each_other(tmp_path):
reg = tmp_path / "reg"
assert _registered_prompt(_dry_submit(tmp_path, reg, "--prompt", "short"), reg) == "short"
pf = tmp_path / "p.md"
pf.write_text("from file\n", encoding="utf-8")
both = _dry_submit(tmp_path, tmp_path / "reg2", "--prompt", "x", "--prompt-file", str(pf))
assert both.returncode == 1 and "not both" in both.stderr
missing = _dry_submit(tmp_path, tmp_path / "reg3", "--prompt-file", str(tmp_path / "absent.md"))
assert missing.returncode == 1 and "cannot read --prompt-file" in missing.stderr
neither = _dry_submit(tmp_path, tmp_path / "reg4")
assert neither.returncode == 1
# relative to the caller's cwd, even though the wrapper cds to --workdir
work = tmp_path / "elsewhere"
work.mkdir()
reg5 = tmp_path / "reg5"
rel = _dry_submit(tmp_path, reg5, "--prompt-file", "p.md", "--workdir", str(work))
assert _registered_prompt(rel, reg5) == "from file\n"
def test_s1d_registry_update_takes_a_prompt_file(tmp_path):
"""Hop 3 (the wrapper's loop type) updates the record by file too."""
reg = tmp_path / "reg"
assert _registered_prompt(_dry_submit(tmp_path, reg, "--prompt", "v1"), reg) == "v1"
jid = next(reg.glob("*.json")).stem
pf = tmp_path / "v2.md"
text = _big_text()
pf.write_text(text, encoding="utf-8")
res = subprocess.run([sys.executable, str(SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py"),
"--registry-dir", str(reg), "update", "--job", jid, "--prompt-file", str(pf)],
capture_output=True, text=True, env=_env(tmp_path), cwd=str(tmp_path), timeout=60)
assert res.returncode == 0, res.stderr
assert json.loads((reg / f"{jid}.json").read_text(encoding="utf-8"))["prompt"] == text
# ---------------------------------------------------------------------------
# S2 - the runner execs the wrapper with a file, not the text
# ---------------------------------------------------------------------------
def test_s2_runner_hands_the_wrapper_a_file(tmp_path, monkeypatch):
rec = tmp_path / "rec"
_fake_wrapper(tmp_path, f"""
printf '%s\\n' "$@" > "{rec}.args"
pf=""
while [ $# -gt 0 ]; do case "$1" in --prompt-file) pf="$2"; shift 2;; *) shift;; esac; done
cp "$pf" "{rec}.prompt"
echo "registered job: fake36"
""")
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
for k in ("MAM_REAL_ROOT", "MAM_JOBS_DIR"):
monkeypatch.delenv(k, raising=False)
from lib_py.loop import runner as lr
r = lr.LoopRunner(["--creator", "c", "--task", "t"])
text = _big_text()
assert r.delegate_job("c", "claude", "Worker", text) == "fake36"
args = Path(f"{rec}.args").read_text().splitlines()
assert "--prompt-file" in args and "--prompt" not in args
assert Path(f"{rec}.prompt").read_text(encoding="utf-8") == text
assert _prompt_leftovers(tmp_path) == []
# ---------------------------------------------------------------------------
# S3 - a failed delegation ends a real loop process with rc 1
# ---------------------------------------------------------------------------
def _loop_env(ctl: Path, extra=None) -> dict:
return _env(ctl, extra={
"WORKSPACE_ROOT": ctl,
"MAM_JOBS_DIR": ctl / ".mam" / "jobs",
"DELEGATE_JOB_LOGS_DIR": ctl / ".mam" / "logs",
"MAM_SESSION_BUSY_RETRY_S": "0",
"MAM_STATE_JSON": json.dumps({"herdr_sessions": [
{"name": "c", "agent": "claude", "role": "creator", "status": "running"},
{"name": "r", "agent": "claude", "role": "reviewer", "status": "running"}]}),
**(extra or {}),
}, drop=("MAM_LOOP_WORKTREE", "MAM_LOOP_BASE", "MAM_VERSION"))
def _run_loop(ctl: Path, env: dict) -> subprocess.CompletedProcess:
plan = ctl / "plan.md"
plan.write_text("# plan\n\ndo it\n", encoding="utf-8")
return subprocess.run([sys.executable, "-m", "lib_py.loop", "--creator", "c", "--task", "t36",
"--reviewer", "r", "--max-loop", "1", "--plan-file", str(plan)],
capture_output=True, text=True, env=env, cwd=str(ctl), timeout=120)
def test_s3_a_wrapper_that_fails_ends_the_loop_with_rc_1(tmp_path):
ctl = _repo(tmp_path / "ctl")
_fake_wrapper(ctl, "echo 'submit refused' >&2\nexit 2\n")
res = _run_loop(ctl, _loop_env(ctl))
assert res.returncode == 1, res.stdout + res.stderr
assert "Failed to register Creator execution job" in res.stderr
assert "Traceback" not in res.stderr
def test_s3b_a_wrapper_that_cannot_start_ends_the_loop_with_rc_1(tmp_path):
"""exec itself fails (no bash on PATH): reported, no traceback, rc 1."""
ctl = _repo(tmp_path / "ctl")
_fake_wrapper(ctl, "echo 'registered job: never'\n")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
git = subprocess.run(["bash", "-c", "command -v git"], capture_output=True, text=True).stdout.strip()
(bin_dir / "git").symlink_to(git)
res = _run_loop(ctl, _loop_env(ctl, {"PATH": bin_dir}))
assert res.returncode == 1, res.stdout + res.stderr
assert "submit failed (exit 126)" in res.stderr
assert "cannot start the wrapper" in res.stderr
assert "Failed to register Creator execution job" in res.stderr
assert "Traceback" not in res.stderr
assert _prompt_leftovers(ctl) == []
# ---------------------------------------------------------------------------
# S4 - the handler: an exec error is a failed submit, on the retry too
# ---------------------------------------------------------------------------
@pytest.fixture
def runner(tmp_path, monkeypatch):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("MAM_SESSION_BUSY_RETRY_S", "0")
for k in ("MAM_REAL_ROOT", "MAM_JOBS_DIR"):
monkeypatch.delenv(k, raising=False)
from lib_py.loop import runner as lr
return lr, lr.LoopRunner(["--creator", "c", "--task", "t"])
def test_s4_an_exec_error_is_a_failed_submit(runner, tmp_path, monkeypatch, capsys):
lr, r = runner
def boom(*a, **k):
raise OSError(errno.E2BIG, "Argument list too long")
monkeypatch.setattr(lr.subprocess, "run", boom)
assert r.delegate_job("c", "claude", "Worker", "p" * 1000) is None
err = capsys.readouterr().err
assert "submit failed (exit 126)" in err
assert "Argument list too long" in err and "prompt 1000 bytes" in err
assert _prompt_leftovers(tmp_path) == []
def test_s4c_a_prompt_file_that_cannot_be_written_is_a_failed_submit(runner, tmp_path, monkeypatch, capsys):
"""Merge review H1: the write, not only the exec, can fail (ENOSPC, EFBIG).
The runner must report it like an exec error and leave no partial file."""
lr, r = runner
real = lr.tempfile.NamedTemporaryFile
class _Broken:
def __init__(self, *a, **k):
self._fh = real(*a, **k)
self.name = self._fh.name
def __enter__(self):
self._fh.__enter__()
return self
def __exit__(self, *exc):
return self._fh.__exit__(*exc)
def write(self, _):
raise OSError(errno.ENOSPC, "No space left on device")
monkeypatch.setattr(lr.tempfile, "NamedTemporaryFile", _Broken)
ran = []
monkeypatch.setattr(lr.subprocess, "run", lambda *a, **k: ran.append(a) or None)
assert r.delegate_job("c", "claude", "Worker", "p" * 1000) is None
err = capsys.readouterr().err
assert "submit failed (exit 126)" in err and "cannot write the prompt file" in err, err
assert "Traceback" not in err
assert ran == [], "the wrapper must not be started without a prompt file"
assert _prompt_leftovers(tmp_path) == []
def test_s1e_an_empty_prompt_file_is_refused(tmp_path):
"""Merge review H3: --prompt-file with nothing in it registered an empty job."""
reg = tmp_path / "jobs"
empty = tmp_path / "empty.md"
empty.write_text("")
res = _dry_submit(tmp_path, reg, "--prompt-file", str(empty))
assert res.returncode != 0 and "is empty" in res.stderr, res.stdout + res.stderr
def test_s4b_an_exec_error_on_the_busy_retry_is_reported_not_raised(runner, tmp_path, monkeypatch, capsys):
lr, r = runner
calls = []
def run(cmd, **k):
calls.append(cmd)
if len(calls) == 1:
return subprocess.CompletedProcess(cmd, 5, "subscriber pid: 1\n", "session busy\n")
raise OSError(errno.EAGAIN, "Resource temporarily unavailable")
monkeypatch.setattr(lr.subprocess, "run", run)
assert r.delegate_job("c", "claude", "Worker", "p") is None
assert len(calls) == 2
err = capsys.readouterr().err
assert "submit failed (exit 5)" in err
assert "retry failed (exit 126)" in err
assert "cannot start the wrapper" in err
assert _prompt_leftovers(tmp_path) == []
# ---------------------------------------------------------------------------
# S3c - a job that cannot be registered after the rebuttal ends the loop with
# rc 1, even when the next review round would pass (real loop process; only
# the agents' outcomes are simulated, by an executable behind the wrapper)
# ---------------------------------------------------------------------------
FAKE_AGENT = r"""
import json, os, sys
from pathlib import Path
args = sys.argv[3:] # argv[1] = kind to refuse, argv[2] = "submit"
opts = dict(zip(args[::2], args[1::2]))
prompt = Path(opts["--prompt-file"]).read_text(encoding="utf-8")
refuse = sys.argv[1]
jobs = Path(opts.get("--registry-dir", ".mam/jobs")) # the shell loop passes none
jobs.mkdir(parents=True, exist_ok=True)
kind = ("comply" if "재심 결과 기각" in prompt else
"adjudication" if "re-adjudication" in prompt else
"arbitration" if "재정(arbitration)" in prompt else
"correction" if "[이의제기 채널]" in prompt else
opts["--role"])
review = "Self-Review" if "Self-Review" in prompt else kind
with open(".mam/calls.log", "a", encoding="utf-8") as log: # what reached the agent, in bytes
log.write("%s\t%d\n" % (review, len(prompt.encode("utf-8"))))
if kind == refuse:
print(kind + " registration refused", file=sys.stderr)
sys.exit(2)
if kind == "adjudication":
report = "no\n\n[ADJUDICATION: OVERRULED]\n"
elif kind == "arbitration":
report = "reviewer is right\n\n[ARBITRATION: REVIEWER]\n"
elif kind == "correction":
report = "I disagree\n\n[REBUT: r]\n"
elif kind == "Reviewer" and "Self-Review" in prompt:
report = "ok\n\n[VERDICT: PASS]\n"
elif kind == "Reviewer": # the first cross review objects, every later one passes
counter = Path(".mam/review-count")
n = int(counter.read_text()) + 1 if counter.exists() else 1
counter.write_text(str(n))
report = "line 3 is wrong\n\n[VERDICT: NOT PASS]\n" if n == 1 else "ok\n\n[VERDICT: PASS]\n"
elif kind == "Planner":
report = "# plan\n\n" + "p" * int(os.environ.get("FAKE_PLAN_BYTES", "5")) + "\n"
else:
grow = int(os.environ.get("FAKE_GROW_BYTES", "0"))
if grow and kind == "Worker": # the implementation: one big new file in the tree
Path("big.txt").write_text(("g" * 63 + "\n") * (grow // 64), encoding="utf-8")
report = "done\n"
jid = "j%d" % len(list(jobs.glob("*.json")))
(jobs / (jid + ".json")).write_text(json.dumps({"job_id": jid, "status": "completed"}))
dest = jobs / jid / "agent-reports"
dest.mkdir(parents=True)
(dest / "report-final.md").write_text(report, encoding="utf-8")
print("registered job: " + jid)
"""
@pytest.mark.parametrize("refuse, message", [
("comply", "Failed to register Creator compliance job."),
("adjudication", "Failed to register re-adjudication job for 'r'."),
("arbitration", "Failed to register Planner arbitration job."),
])
def test_s3c_a_registration_failure_after_a_rebuttal_is_never_a_success(tmp_path, refuse, message):
ctl = _repo(tmp_path / "ctl")
agent = ctl / "fake-agent.py"
agent.write_text(FAKE_AGENT, encoding="utf-8")
_fake_wrapper(ctl, f'exec "{sys.executable}" "{agent}" {refuse} "$@"\n')
extra = {}
argv = ["--max-loop", "2"]
if refuse == "arbitration": # arbitration needs --plan and a planner
extra["MAM_STATE_JSON"] = json.dumps({"herdr_sessions": [
{"name": "c", "agent": "claude", "role": "creator", "status": "running"},
{"name": "r", "agent": "claude", "role": "reviewer", "status": "running"},
{"name": "p", "agent": "claude", "role": "planner", "status": "running"}]})
argv += ["--plan", "--plan-talk", "0"]
else:
plan = ctl / "plan.md"
plan.write_text("# plan\n\ndo it\n", encoding="utf-8")
argv += ["--plan-file", str(plan)]
res = subprocess.run([sys.executable, "-m", "lib_py.loop", "--creator", "c", "--task", "t36",
"--reviewer", "r", *argv],
capture_output=True, text=True, env=_loop_env(ctl, extra), cwd=str(ctl), timeout=120)
assert message in res.stderr, res.stdout + res.stderr
assert res.returncode == 1, res.stdout + res.stderr
assert "100% PASS" not in res.stdout
assert "Traceback" not in res.stderr
# ---------------------------------------------------------------------------
# S5 - the former blind spot: a 131,072..200,000-byte diff goes through whole
# ---------------------------------------------------------------------------
def _collect(repo: Path, base: str, head: str, extra=None) -> bytes:
env = {k: v for k, v in os.environ.items() if k not in ("MAM_DIFF_MAX_BYTES", "MAM_DIFF_MAX_LINES")}
env.update(extra or {})
res = subprocess.run(["bash", "-c", 'source "$1" && mam_collect_changes_diff "$2" "$3" "$4"',
"_", str(DIFF_COLLECT), str(repo), base, head],
capture_output=True, env=env, timeout=60)
assert res.returncode == 0, res.stderr
return res.stdout
def test_s5_a_diff_in_the_former_blind_spot_is_delivered_whole(tmp_path):
repo = _repo(tmp_path / "src")
base = _git(repo, "rev-parse", "HEAD")
(repo / "big.txt").write_text(_big_text(150_000), encoding="utf-8")
_git(repo, "add", "big.txt")
_git(repo, "commit", "-q", "-m", "grow")
head = _git(repo, "rev-parse", "HEAD")
diff = _collect(repo, base, head)
assert TRUNCATED.encode() not in diff
assert MAX_ARG_STRLEN < len(diff) <= 200_000
pf = tmp_path / "diff.md"
pf.write_bytes(diff)
reg = tmp_path / "reg"
assert _registered_prompt(_dry_submit(tmp_path, reg, "--prompt-file", str(pf)), reg) == diff.decode("utf-8")
# the collector cap itself is unchanged: over it, the diff is still truncated and marked
assert TRUNCATED.encode() in _collect(repo, base, head, {"MAM_DIFF_MAX_BYTES": "100000"})
# ---------------------------------------------------------------------------
# S6 - lib.sh's submit helper (create_session's onboarding and --submit-job)
# and the delegation CLI behind it take a prompt over the cap by file
# ---------------------------------------------------------------------------
LIB_SH = SKILLS / "lib.sh"
def _helper_env(root: Path, extra=None) -> dict:
return _env(root, extra={"MAM_JOBS_DIR": root / "reg", **(extra or {})})
def test_s6_delegation_cli_submit_takes_a_prompt_file(tmp_path):
text = _big_text()
pf = tmp_path / "p.md"
pf.write_text(text, encoding="utf-8")
res = subprocess.run([sys.executable, "-m", "lib_py.delegation.cli", "submit", "--prompt-file", str(pf),
"--agent", "claude-code", "--agent-session", "herdr:x"],
capture_output=True, text=True, env=_helper_env(tmp_path), cwd=str(tmp_path), timeout=60)
assert res.returncode == 0, res.stderr
jid = res.stdout.strip().splitlines()[-1]
assert json.loads((tmp_path / "reg" / f"{jid}.json").read_text(encoding="utf-8"))["prompt"] == text
both = subprocess.run([sys.executable, "-m", "lib_py.delegation.cli", "submit", "--prompt", "x",
"--prompt-file", str(pf)],
capture_output=True, text=True, env=_helper_env(tmp_path), cwd=str(tmp_path), timeout=60)
assert both.returncode == 2 and "not allowed with argument" in both.stderr
@pytest.mark.parametrize("delegation_py", ["1", "0"]) # the Python helper and its legacy twin
def test_s6b_delegate_submit_job_passes_a_prompt_over_the_cap(tmp_path, delegation_py):
text = _big_text()
pf = tmp_path / "p.md"
pf.write_text(text, encoding="utf-8")
# The prompt reaches the shell function as a bash argument (no exec, no cap),
# exactly as create_session.sh hands it over.
res = subprocess.run(["bash", "-c", 'source "$1" && delegate_submit_job "$(cat "$2")" claude-code herdr:x',
"_", str(LIB_SH), str(pf)],
capture_output=True, text=True, cwd=str(tmp_path), timeout=60,
env=_helper_env(tmp_path, {"MAM_DELEGATION_PY": delegation_py}))
assert res.returncode == 0, res.stderr
jid = res.stdout.strip().splitlines()[-1]
assert json.loads((tmp_path / "reg" / f"{jid}.json").read_text(encoding="utf-8"))["prompt"] == text
assert sorted(p.name for p in (tmp_path / ".mam").glob("prompt-*")) == []
# ---------------------------------------------------------------------------
# S7 - the legacy shell loop (MAM_LOOP_PY=0): the real run_loop.sh hands every
# prompt over by file, and a registration failure never ends it with rc 0.
# run_loop.sh always runs the wrapper next to itself, so the skills tree is
# copied under tmp_path (as the mam_sandbox fixture does) and only that copy's
# wrapper is replaced by the agent simulator.
# ---------------------------------------------------------------------------
def _shell_ctl(tmp_path: Path, refuse: str) -> Path:
import shutil
ctl = tmp_path / "ctl"
shutil.copytree(SKILLS, ctl / ".agents" / "skills", ignore=shutil.ignore_patterns("__pycache__"))
(ctl / ".gitignore").write_text(".mam/\n.agents/reports/\n")
agent = ctl / "fake-agent.py"
agent.write_text(FAKE_AGENT, encoding="utf-8")
_fake_wrapper(ctl, f'exec "{sys.executable}" "{agent}" {refuse} "$@"\n')
_repo(ctl) # the base holds the copied tree, so the diff is only what the "Creator" writes
_git(ctl, "add", "-A")
_git(ctl, "commit", "-q", "-m", "skills")
(ctl / ".mam").mkdir()
(ctl / ".mam" / "agent-sessions.yaml").write_text(
"herdr_sessions:\n"
"- {name: c, agent: claude, role: creator, status: running}\n"
"- {name: r, agent: claude, role: reviewer, status: running}\n"
"- {name: p, agent: claude, role: planner, status: running}\n", encoding="utf-8")
return ctl
def _run_shell_loop(ctl: Path, argv, extra=None) -> subprocess.CompletedProcess:
env = _env(ctl, extra={
"MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1", # no /tmp snapshot of the skills
"MAM_LOOP_MARKER": ctl / ".mam" / "loop-marker",
"AGENT_SESSIONS_YAML": ctl / ".mam" / "agent-sessions.yaml",
"WORKSPACE_ROOT": ctl, **(extra or {})},
drop=("MAM_LOOP_WORKTREE", "MAM_LOOP_BASE", "MAM_VERSION", "MAM_STATE_JSON"))
run_loop = ctl / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
return subprocess.run(["bash", str(run_loop), "--creator", "c", "--task", "t36", "--reviewer", "r", *argv],
capture_output=True, text=True, env=env, cwd=str(ctl), timeout=300)
def _calls(ctl: Path) -> list:
return [tuple(line.split("\t")) for line in (ctl / ".mam" / "calls.log").read_text().splitlines()]
def test_s7_shell_loop_hands_prompts_over_the_cap_to_the_wrapper(tmp_path):
ctl = _shell_ctl(tmp_path, "none")
# a 140,000-byte plan makes the execution prompt, and a 150,000-byte new
# file makes the collected diff (so the review prompt), exceed the cap
res = _run_shell_loop(ctl, ["--plan", "--plan-talk", "0", "--max-loop", "2"],
{"FAKE_PLAN_BYTES": BIG, "FAKE_GROW_BYTES": 150_000})
assert res.returncode == 0, res.stdout + res.stderr
assert "Argument list too long" not in res.stderr
sizes = {}
for kind, n in _calls(ctl):
sizes.setdefault(kind, []).append(int(n))
assert max(sizes["Worker"]) > MAX_ARG_STRLEN # execution prompt with the plan
assert max(sizes["Reviewer"]) > MAX_ARG_STRLEN # cross review with the diff
assert sorted(p.name for p in (ctl / ".mam").glob("prompt-*")) == []
@pytest.mark.parametrize("refuse, argv", [
("Worker", ["--max-loop", "1"]), # the implementation job
("comply", ["--max-loop", "2"]), # the compliance job after an overruled rebuttal; round 2 would pass
])
def test_s7b_shell_loop_ends_nonzero_when_a_job_cannot_be_registered(tmp_path, refuse, argv):
"""The shell loop runs under set -e: a failed submit inside $(...) ends it
at that line with the wrapper's code, so the cause must reach stderr."""
ctl = _shell_ctl(tmp_path, refuse)
res = _run_shell_loop(ctl, argv)
out = res.stdout + res.stderr
assert res.returncode == 2, out
assert f"{refuse} registration refused" in res.stderr
assert "delegate_job_safe failed (exit 2)" in res.stderr
assert "100% PASS" not in out
# ---------------------------------------------------------------------------
# S8 - create_session's onboarding / --submit-job: the task reaches the agent
# through a brief in the job directory, never inside the injected text
# (herdr `agent prompt` takes that text as one argv string). Real
# create_session.sh, registry and send_keys_safe; herdr and the agent CLIs are
# the conftest executables. From the review of job cc849cbf (codex).
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("size", [100, 130_500]) # the second is under the cap on its own
def test_s8_create_session_task_reaches_the_pane_through_a_brief(mam_sandbox, mock_herdr, mock_agents,
monkeypatch, size):
import shutil
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "1")
monkeypatch.setenv("SKS_DELIVERY_GRACE_S", "0")
script = mam_sandbox / ".agents/skills/multi-agent-mux-create/scripts/create_session.sh"
prompt = "q" * size
cmd = ["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude",
"--role", "Creator", "--session", "review36-creator-claude", "--submit-job", prompt]
trace = mam_sandbox / "exec.trace"
strace = shutil.which("strace")
if strace: # the kernel's own verdict on every exec, when available
cmd = [strace, "-f", "-s", "100", "-e", "trace=execve", "-o", str(trace)] + cmd
result = subprocess.run(cmd, cwd=mam_sandbox, capture_output=True, text=True, timeout=150)
out = result.stdout + result.stderr
records = [json.loads(p.read_text()) for p in (mam_sandbox / ".mam/jobs").glob("*.json")]
assert [r for r in records if r.get("prompt") == prompt], out
jid = next(r["job_id"] for r in records if r.get("prompt") == prompt)
brief = mam_sandbox / ".mam/jobs" / jid / "brief.md"
assert brief.read_text() == prompt
calls = [str(c) for c in json.loads(mock_herdr.read_text())["calls"]]
injected = [c for c in calls if "agent" in c and "prompt" in c and jid in c]
assert injected, f"rc={result.returncode}; no onboarding text reached herdr\n{out[-3000:]}"
text = injected[0]
assert str(brief) in text and prompt not in text
# the #23 ack contract is unchanged: the publish command names this dispatch
assert "--expect-dispatch-token" in text and "--event started" in text
if strace:
assert not [line for line in trace.read_text().splitlines() if "E2BIG" in line]
# ---------------------------------------------------------------------------
# S9 - the shell loop's delegate_job_safe: a prompt file that cannot be written
# is removed, and the cause reaches stderr (callers capture stdout). The real
# function is taken from run_loop.sh; the write fails for real (RLIMIT_FSIZE).
# From the review of job cc849cbf (codex).
# ---------------------------------------------------------------------------
def test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why(tmp_path):
source = (SKILLS / "multi-agent-mux-loop/scripts/run_loop.sh").read_text()
function = source[source.index("delegate_job_safe() {"):source.index("\nlog_info() {")]
(tmp_path / "input.md").write_text("x" * 2000)
harness = tmp_path / "run.sh"
harness.write_text('set -euo pipefail\nREPO_ROOT="$PWD"\nMAM_REAL_ROOT="$PWD"\n'
'log_error() { printf "%s\\n" "$1"; }\n' + function +
'\ntrap "" XFSZ\nulimit -f 1\n'
'output=$(delegate_job_safe submit --prompt "$(cat input.md)")\n')
result = subprocess.run(["bash", str(harness)], cwd=tmp_path, capture_output=True, text=True, timeout=60)
assert result.returncode != 0
if sys.platform == "linux":
# bash's own wording for EFBIG; macOS bash 3.2 prints nothing here
# (merge review N1), so only the portable line below is the contract
assert "File too large" in result.stderr
leftovers = list((tmp_path / ".mam").glob("prompt-shloop-*"))
assert not leftovers, [(p.name, p.stat().st_size) for p in leftovers]
assert "delegate_job_safe: cannot write the prompt file" in result.stderr