Issue #34: the loop waits for the creator job's sub-jobs, reviewers get the sub-job table as evidence, a run with sub-jobs voids a round whose source changed during review, and a pane caller reaches an agent of another workspace on its own server. VERSIONS.md carries the entry; the merge-side reports (round 1 @ed293dc and round 2 @e80186f with identity sidecars, baseed293dc-> heade80186f, evidence review-tree) are archived under .agents/reports/<session>/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
16 KiB
🔍 Review Report — PR #38 병합 전 독립 리뷰 (job 107d435d)
- Reviewer:
planner-reviewer-claude-01(role: planner,reviewer) · 2026-09-25 - 대상: PR #38
fix/issue-34@ed293dc, basemain 7a4a0c2(v4.7.3). 6커밋, 11파일+924/−16,git diff --check클린. - 검증 경로:
/var/folders/.../T/mam-review/loop-20260925T055502Z-a37cf1/1. identity 확인:HEAD = ed293dce…67cf,HEAD^{tree} = 671b0e59…899c— 브리프와 일치. - 안전 규칙 준수: 검증 경로·소스 워크트리(
multi-agent-mux-wt/pr-38)·본 체크아웃(main 7a4a0c2) 모두 수정·커밋·checkout 없음. 등록부 실험은 scratchpad 임시 등록부에서registry.py를 직접 호출(래퍼 미사용, 실제 herdr 에 잡 생성 없음), 러너 실험은 in-process(env -u MAM_VERSION, 임시 git 저장소), lib.sh 는 함수 추출 +env -i. 실제.mam·데몬·세션·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 이 pane 의HERDR_PANE_ID/HERDR_WORKSPACE_ID는 읽기만 했다.
결론 요약
병합을 막을 결함은 없다. 부모 기록·자손 대기·리뷰 근거 표·stale 가드·같은 서버 다른 워크스페이스 조회 규칙을 각각 독립 실험으로 확인했고, 새 테스트 17건 중 16건이 수정 전 소스에서 실패한다. 비차단 관찰 4건(K1–K4). 그중 K1(귀속되지 않은 열린 잡만 있을 때 판정 규칙 문구가 없음)은 브리프 항목 2·4 의 "충분한 근거" 질문에 직접 닿는다.
1. 부모 기록 (항목 1)
| 확인 | 결과 |
|---|---|
--parent-job 검증 |
registry.py register(:540-544) 가 등록부에 없는 부모를 unknown parent job <id> + rc 1 로 거부. 래퍼(:217-226)는 명시 --parent-job 을 그대로 넘겨 같은 검증에 걸리게 하고, 기본 MAM_JOB_ID 는 이 등록부에 그 레코드가 있을 때만 쓰며 없으면 WARNING: … registering without a parent job 후 부모 없이 등록 — 두 층의 규칙이 일치한다 |
| 자기 참조 | --job-id X --parent-job X 는 등록 시점에 X 가 없어 거부됨(실측). update 에는 --parent-job 이 없어 부모는 등록 후 불변 → 정상 경로로는 순환이 만들어질 수 없다 |
| 순환 방어 | 레코드 파일을 직접 고쳐 root.parent = grandchild 순환을 만든 뒤 descendants_of 를 돌려도 종료한다(seen 집합, 방문 1회): root 기준 [(child,1),(grandchild,2)], grandchild 기준 [(root,1),(child,2)] |
depth |
저장하지 않고 BFS 에서 계산(자식 1, 손자 2 — 실측) |
| 비용 | descendants_of 는 폴링(3 s)마다 list_jobs 전체 스캔 1회(레코드 수 N 의 JSON 읽기). _subjobs_block 은 회차마다 root 수만큼 스캔 + unattributed_open_jobs 1회. 수백 건이면 문제없고 수천 건이면 3 s 폴링당 수천 파일 읽기 — 등록부 정리 정책이 없는 오래된 제어 평면에서 체감될 수 있다(K4) |
MAM_JOB_TREE_MAX(50) |
넘으면 log_error 후 기다리지 않고 리뷰로 간다. 안전한 쪽인가: 열린 자손은 [하위 위임 잡] 표에 OPEN 으로 나오고 SUBJOB_RULE 이 NOT PASS 를 지시하므로 "조용히 통과"는 아니다. 다만 51개 이상의 행이 프롬프트에 붙는다 |
2. 부모 ID 전달 (항목 2)
- 로컬 에이전트: 래퍼가 주입문 끝(
_instructions, test18 문구 보존)과brief.md의Parent for sub-delegations줄(:251,:427), create 의 온보딩 주입문(create_session.sh:646)으로 알린다. 에이전트가 붙이지 않으면 추적이 빠지는 것은 사실이고, 안전망은 두 가지다: (a)[귀속되지 않은 열린 잡]줄 —unattributed_open_jobs가 "부모 없음 + pending/running + 이 루프가 만든 잡 제외 + creator 잡 등록 이후" 로 고른다(실측: 루프 자신의 뒤이은 잡은 제외, 남의 root 잡은 표시); (b)--wait-open-jobs로 그 잡들까지 대기(기본 off — 같은 제어 평면의 무관한 잡이 루프를 잡을 수 있으므로; 문서와 일치). - K1 (비차단, 근거 충분성): 하위 잡이 하나도 없고 귀속되지 않은 열린 잡만 있을 때 블록은
[하위 위임 잡] 없음 … [귀속되지 않은 열린 잡]: <id>(…)만 담고 SUBJOB_RULE 을 붙이지 않는다(_subjobs_block의if not rows:분기 — 실측:has rule: False). SUBJOB_RULE 자체도 "하위 잡"만 말하고 귀속되지 않은 잡을 어떻게 볼지는 말하지 않는다. 즉 "creator 가--parent-job을 빠뜨린" 바로 그 경우에 리뷰어는 사실은 받지만 지시는 받지 못한다. 한 문장("귀속되지 않은 열린 잡이 이 작업의 하위 위임으로 보이면 OPEN 하위 잡과 같이 취급")을 stray 줄 뒤에 붙이면 닫힌다. - 원격 러너:
MAM_JOB_ID가 자동 부모가 되지만 원격 호스트의 등록부에는 그 잡이 없을 수 있어 "있을 때만" 규칙이 맞다(래퍼 주석과 일치).
3. 자손 대기 (항목 3)
wait_for_job_tree는 실행(:1291)·수정(:1520)·순응(:1591) 세 creator 잡 모두에 걸리고,round_roots에 누적된다.MAM_CHILD_WAIT_S(3900) 의 기준점은wait_for_descendants진입 시각 = creator 잡 종결 시점(문서와 일치). 초과 시log_warn후 False 를 돌려주지만wait_for_job_tree는 root 의 결과만 반환하므로 루프는 리뷰로 진행하고 열린 잡은 표에 OPEN 으로 남는다(설계대로).interrupted: 시그널 핸들러가 체크포인트 저장 후sys.exit(128+signum)하므로(_handle_signal) 폴링 루프의if self.interrupted: return False는 실제로는 닿지 않는 방어 코드다 — 무해.- 분류(
_subjob_state)와get_feedback의 실제 값: 래퍼는 마감 시close --request deadline_timeout뒤publish_event --event error --detail deadline_timeout(inject 단계면agent_blocked)을 남기고(:928-930), preflight/idle 실패는cancelled --detail session_busy또는 새target_not_found(:896,:952), create 롤백은cancelled "rolled back by create_session"이다.get_feedback은 마지막completed/error(감사 로그) 또는 events.log 의 detail 을 돌려주므로ORCHESTRATOR_CLOSED튜플·startswith("rolled back")·session_busy|target_not_found가 정확히 그 문자열들과 맞는다. 사유 없는error를 CLOSED-UNCONFIRMED 로 보는 규칙은 "에이전트 자신의 보고가 아니다" 는 뜻에서 보수적이며 옳다.
4. 리뷰어 근거 (항목 4)
in-process 실측(_subjobs_block):
| 상황 | 결과 |
|---|---|
| 하위 잡 없음·stray 없음 | '' — 프롬프트 바이트 동일 |
이 루프가 뒤에 만든 잡(created_jobs)만 존재 |
'' — 리뷰어 자신의 잡·앞 회차 리뷰 잡은 stray 로 오인되지 않는다(회차의 블록은 리뷰 잡 위임 전에 계산된다) |
| 남의 root 잡(부모 없음, creator 잡 이후 등록) | 블록 생성, stray 줄에 id 표시, 규칙 문구 없음(K1) |
| pending 자식 1개 | 표(| OPEN | - | 1 |) + 집계 + SUBJOB_RULE |
descendants_of 가 depth 를 주고 _subjob_state 가 상태·사유를 주므로 표의 열은 정확하다. 표는 앞 회차 creator 잡의 자손까지 매 회차 다시 읽는다(round_roots 누적, T9).
5. stale 가드 — 옵션 없는 루프 (항목 5)
_fingerprint(runner.py:954-972)는 head 와, .mam(mam_dir)·jobs_dir·감사 로그 디렉터리(DELEGATE_JOB_LOGS_DIR, 상대면 workspace_root 기준으로 결합)를 git add -A -- . :(exclude)<rel> 로 뺀 tree 다. in-process 실측(임시 git 저장소, .gitignore 없음 — 즉 .mam 이 무시되지 않는 최악 조건):
| 상황 | 결과 |
|---|---|
DELEGATE_JOB_LOGS_DIR=.mam/delegate_job_logs(상대) 로 감사 로그·jobs/*.json 을 새로 쓴 뒤 |
지문 불변 — 제어 평면 쓰기는 제외된다 |
| 소스에 untracked 파일 1개 추가 | 지문 변경 |
| 감사 로그 디렉터리가 소스 밖(절대경로) | relative_to 가 ValueError → 제외 목록에서 빠지고 지문 계산은 정상 |
- 켜지는 조건:
light = self._fingerprint()는not identity and subjobs(:1350-1352) — 하위 잡 표나 stray 줄이 있을 때만이다(1d4f338). 하위 위임 없는 루프는 옛 동작 그대로라test_tier4_e2e.py::test_e2e_scenario5류(소스에 매 잡 바뀌는 untracked 가 있는 경우)가 다시 살아난다 — 이번 실행에서 tier4 포함 통과(§7). - 적용 지점: 셀프 리뷰 뒤(
:1368), 리뷰 판정 수집 뒤(:1463, identity 모드의 B7 과elif로 배타), 승격 직전(:1595, identity 모드의 B10 은:1613로 별도). identity 모드에서는light가None으로 남아 이중 적용이 없다. - 지문을 만들 수 없으면(예: dirty 서브모듈) 경고 1회 후 가드 off — 옵션 없는 모드의 기존 동작으로 되돌아가는 것이라 수용 가능하다.
6. 같은 서버 다른 워크스페이스 위임 (항목 6)
_herdr_scope_is_pane_inherited(lib.sh:1464-1473)와 mam_herdr 의 elif(:1485-1486)를 함수 추출 + env -i 로 10가지 조합에 돌렸다(전부 설계와 일치):
| 호출 | 환경 | 결과 |
|---|---|---|
agent/has-session/kill-session(-L 접두 포함) |
HERDR_WORKSPACE_ID == HERDR_PANE_ID 의 워크스페이스, 같은 서버 |
WS id 만 제거(pane·tab id 유지) |
new-session, pane split |
동일 | 유지 — pane 배치·워크스페이스 패킹(#7) 불변 |
agent |
WS id ≠ pane 의 워크스페이스(어긋난 스코프) | 유지(의도된 스코프) |
agent |
pane id 없음(pane 밖 명시 스코프) | 유지 |
agent |
기본 서버 pane → 이름 있는 서버 | CROSS-all3 — #23 규칙이 if 로 먼저 잡아 세 id 모두 제거, 이 규칙은 elif 라 닿지 않는다(충돌 없음) |
agent |
대상 미지정(앰비언트) | WS id 만 제거(같은 서버이므로 같은 취급) |
- 이 pane 의 실제 값도 규칙의 전제(
HERDR_PANE_ID = "<ws>:<pane>",HERDR_WORKSPACE_ID = <ws>)와 맞았다(값은 읽기만). - 이름 유일성 근거(herdr
--skill문구)는 코드 주석에 인용돼 있고, T10/T11 이 두 방향을 고정한다. target_not_found: preflight 의has-session실패가PREFLIGHT_REASON을 세우고(:952)finalize_unfinished가cancelled --detail target_not_found로 닫는다(:896). rc 는 종전과 같은 5 다(T12 가returncode == 5를 고정) — 즉 이벤트 detail 은 정확해졌지만 종료 코드는 "busy" 와 같아서, 루프의 busy 1회 재시도(MAM_SESSION_BUSY_RETRY_S)가 없는 대상에도 한 번 더 돌아간다(K3).
7. 새 테스트 (항목 7)
- 실제 경로: T1·T12 는 실제 래퍼(
--agent human, 도달 불가 브로커)와registry.py로 등록·종결한다. T3T9 는T12 는 mock herdr 위의 샌드박스.python -m lib_py.loop를 하위 프로세스로 띄우고 가짜 래퍼가 실제 래퍼(FAKE_REAL_WRAPPER)로 하위 잡을 등록해 자손 대기·표·가드를 통과시킨다. T10HOME·WORKSPACE_ROOT·DELEGATE_JOB_LOGS_DIR는tmp_path. - 수정 없이는 실패하는가 — 확인함: 검증 경로 사본에서 PR 이 바꾼 소스 6파일을
7a4a0c2판으로 되돌리고 새 파일을 돌린 결과 16 failed / 1 passed(16.7 s). 통과 1건은 T11("호출자가 고른 워크스페이스 id 는 여전히 스코프") — 정정 전에도 성립해야 하는 불변 조건이다. - 검증 경로에서의 실행 (9파일: 새 파일 + issue5 phase1·tier4 e2e·issue21 loop B/acceptance·issue23/24·loop_cli·issue25·herdr shim contract):
→ 1 failed, 243 passed in 1460.19s (0:24:20)
FAILED tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts
- 새 파일 17건 전부 통과,
test_tier4_e2e.py::test_e2e_scenario5(브리프 항목 5 의 회귀) 통과(873 s — 이 호스트에서 다른 스위트가 동시에 돌아 load avg 2.5 였다), registry CLI 스냅샷·help 캡처(issue5 phase1) 통과 →--parent-job추가가 기존 CLI 계약을 깨지 않는다. - 실패 1건은 부하 flake 로 판정한다: s7c 는 create/registry 의
started증거 시험이며 PR 은 그 경로를 건드리지 않았다(create 는 주입문에 문장 하나 추가뿐). 합동 실행에서 40 s 걸려_seed_job단계(--ack-timeout 2)에서 실패했고, 단독 재실행에서는 s7b 와 함께 2 passed in 10.8 s 로 통과했다. - 격리: 실제
.mam/jobs·.mam/delegate_job_logs전후 차이 0 / 차이 0. 검증 경로 tracked 파일 변화 없음(HEADed293dc), 본 체크아웃main 7a4a0c2. - 실행 시간: 새 파일 자체는 되돌린 사본 기준 17 s 안팎이고, 합동 실행의 24 분은 tier4 e2e(약 17 분)와 호스트 부하 몫이다.
8. 문서 (항목 8)
- loop SKILL.md
:214(--wait-open-jobs표),:221(하위 위임 잡: 대기 범위·MAM_CHILD_WAIT_S기준점·MAM_JOB_TREE_MAX·표/규칙·회차 누적),:223(stale 가드) — 코드와 일치한다. - delegate-job SKILL.md
:49-53·Best Practices, MULTI_AGENT_RULES(ko/en):154—--parent-job의무와 기본값·거부 규칙이 서술돼 있다. - 래퍼 usage 와
brief.md의Parent for sub-delegations줄, create 온보딩 주입문 — 모두 같은 문장이다.
9. 병합 차단 여부 (항목 9)
버전 락스텝 bump 부재(lib.sh:32 4.7.3, 오케스트레이터가 4.7.4 로 처리 예정) 외에 병합을 막을 결함은 없다.
| # | 비차단 관찰 | 근거 / 권고 |
|---|---|---|
| K1 | 귀속되지 않은 열린 잡만 있을 때 블록에 SUBJOB_RULE 이 없고, 규칙 문구도 그 잡을 언급하지 않는다 — --parent-job 을 빠뜨린 바로 그 경우에 리뷰어가 지시 없이 사실만 받는다 |
runner.py:697-724 의 if not rows: 분기, 실측 has rule: False. stray 줄 뒤에 한 문장 권고 |
| K2 | MAM_JOB_TREE_MAX 초과 시 대기를 건너뛰고 리뷰로 간다 — 표의 OPEN 행과 규칙이 NOT PASS 를 유도하므로 조용한 통과는 아니지만, 50+ 행이 프롬프트에 붙는다 |
:646-654. 정보 |
| K3 | target_not_found 는 이벤트 detail 만 바뀌고 래퍼 rc 는 session_busy 와 같은 5 라 루프의 busy 1회 재시도가 없는 대상에도 돈다 |
:896, :952, T12 returncode == 5. rc 분리 권고(후속) |
| K4 | descendants_of/unattributed_open_jobs 는 폴링(3 s)마다 등록부 전체 JSON 을 읽는다 — 수천 건 누적된 제어 평면에서 체감 가능 |
registry.py:259-282. 등록부 정리 정책 또는 인덱스가 생기기 전까지는 정보 |
10. 교차검증 가이드
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T055502Z-a37cf1/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # ed293dc… / 671b0e5…
# 등록부: 부모 기록·list --parent·미지 부모/자기 참조 거부 (임시 등록부, registry.py 직접)
# descendants_of: 손자 depth 2, 레코드 파일로 순환을 만들어도 종료
# 러너 in-process (env -u MAM_VERSION): _subjobs_block '' / stray / child 표, _fingerprint 의 .mam 제외
# lib.sh: _herdr_scope_is_pane_inherited 10케이스 (§6)
# 되돌린 사본: 소스 6파일을 7a4a0c2 판으로 → tests/test_issue34_job_tree.py → 16 failed / 1 passed
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts) # 단독 통과
[VERDICT: PASS]