Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-ed0e1492.md
T
GodopuandClaude Opus 5.5 53f87e4560 release(v4.8.0): bump framework and eight skills to 4.8.0 in lockstep; archive PR #41 reviews (three rounds)
Issue #33 (job budget, wall_clock_timeout vs deadline_timeout) and
Issue #30 (notify-only stall watcher). Reviews of PR #41 in the merge
workspace: round 1 (@1673818) claude N1 / codex R1 R2 NOT PASS, agy grok
PASS; round 2 (@af0a2a2) grok N1 / codex D1 D2 NOT PASS, claude agy PASS;
round 3 (@9200562) 4/4 PASS. Reports and the round-3 identity sidecars go
to .agents/reports/<session>/. The design consensus on items A-D is in the
VERSIONS.md entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-26 16:08:37 +09:00

166 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔍 Review Report — PR #41 병합 전 독립 리뷰 + 설계 합의 (job `ed0e1492`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
- **대상**: PR #41 `fix/issue-30-33` **@ `1673818`**, base `main 8ea65bb` (v4.7.4). 6커밋, 30파일 `+2047/−95`, `git diff --check` 클린.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T025230Z-a30a6d/1`. **identity 확인**: `HEAD = 1673818b…e91f`, `HEAD^{tree} = 00d5e03e…9e92` — 브리프와 일치.
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-41`)·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 in-process 러너(`env -u MAM_VERSION`)·래퍼 `--dry-run`(`--agent human`, 도달 불가 브로커)·되돌린 사본에서만. **stall_watch 를 실제 세션에 붙이지 않았고**, 실제 herdr 서버에 위임 잡을 만들지 않았다. push·merge·PR 상태 변경 없음.
## 결론 요약
**병합을 막을 결함은 없다.** #33 의 예산 전달·우선순위와 두 시계 라벨, #30 감시자의 "알림 전용" 성질(발행하는 이벤트가 `progress` 뿐, 잡을 닫는 코드 경로 없음)을 코드·실험·테스트로 확인했다. 경계 기준으로 보면 bash 에 새 **판정** 이 두 곳 남아 있고(§4 표: 디스크 heartbeat 유예의 시계 판정, 감시 대상 제외 판정) 이는 항목 4 이전 대상이므로 비차단으로 분류한다. 비차단 관찰 7건(M1–M7). 설계 합의 4항목은 §8 에 별도로 답했다.
## 1. #33 예산 (항목 1)
| 확인 | 결과 |
|---|---|
| 래퍼 우선순위 | `--timeout` > `MAM_JOB_TIMEOUT_S` > 3600. `--dry-run` 실측으로 등록된 `timeout_sec`: 없음→3600, env 900→900, env 900 + `--timeout 120`→**120**, env `abc`→3600 + `WARNING: … ignored (not a positive integer)`, env `-7`→3600 + 경고 |
| 러너 우선순위 | `job_budget`(`runner.py:595-600`): `--job-timeout` > env(양의 정수) > 3600. in-process 실측: 기본 3600/대기 3900, env 900→900/1200, flag 120 > env 900→120/420, env `abc`·`0`→3600(**무언**, M4), `--job-timeout 0/-5/x` → exit 1 |
| 모든 위임에 전달되는가 | `contract_args()`(`:464-470`)가 **항상** `--timeout <budget>` 을 앞에 붙이고, 구현·리뷰·수정·재심·중재·순응 잡은 전부 `_dispatch → delegate_job → _run_cmd(… + contract_args())` 를 지나므로 예외가 없다(실측 `['--timeout','3600']`). 래퍼 쪽에서는 `--timeout` 플래그가 env 를 이기므로 루프의 예산이 등록된다(B2) |
| 대기 상한 | `job_wait_s = budget + MAM_WAIT_GRACE_S(300)`(`:603-609`); `wait_for_job` 기본 `max_wait` 와 `MAM_CHILD_WAIT_S` 기본값이 이를 따르고 명시 `MAM_CHILD_WAIT_S` 는 존중(`:729-732`, 로그에 출처 표기). `MAM_WAIT_GRACE_S=10` → 610 실측 |
| 원격 잡 | `remote.py` 는 `job.timeout_sec`(등록된 예산)로 명령을 죽이므로 예산이 그대로 적용된다; 라벨은 §2 |
## 2. #33 라벨 (항목 2)
- **상수**: `job_subscriber.py:53-61` `RC_COMPLETED/ERROR/NO_EVENT/BROKER_UNAVAILABLE/WALL_CLOCK = 0/1/2/3/4`, 래퍼 `:144` `SUB_RC_*` 동일값. `test_issue5_phase1_delegation.py::test_wrapper_exit_code_mapping_literals_are_frozen` 이 `SUB_RC_*` 사전을 `RC_*` 와 **사전 단위로 비교**하고, 래퍼가 숫자로 비교하는 자리가 없음·구독자가 숫자 `return` 을 하지 않음을 정규식으로 고정한다.
- **래퍼 종결 경로별 라벨**(`finalize_unfinished`, `:860-1000`):
| 경로 | 라벨 |
|---|---|
| 구독자 rc 4 | `clock=wall` → `close --request wall_clock_timeout` + `error --detail wall_clock_timeout` |
| 구독자 rc 2 | `clock=idle` → `deadline_timeout` |
| rc 2 뒤 디스크 heartbeat 유예가 `$deadline` 에 닿음 | `FINALIZE_CLOCK=wall` → `wall_clock_timeout`(R3) |
| rc 3(broker_unavailable) 재관측이 `$deadline` 에 닿음 | 같은 규칙으로 `wall` |
| inject 단계 | `agent_blocked` 유지(`phase != inject` 조건) |
| preflight/idle_wait | `cancelled`(`session_busy`/`target_not_found`) 유지 |
- 등록부 `close --request` 에 `wall_clock_timeout` 추가(`registry.py:424`), `engine.REQUEST_TO_STATUS["wall_clock_timeout"]="error"`, #34 표의 `ORCHESTRATOR_CLOSED` 에 포함 + `.rstrip(":")` 로 원격 문구 `wall_clock_timeout: agent command exceeded…` 도 첫 단어 판정을 통과(B5).
- `delegate-job wait`: rc 4→2 매핑(`:1214`)으로 하위 호환(B7, G-8 은 idle 30 s/wall 0.5 s 로 시계를 분리해 결정적).
- 벽시계로 닫힌 뒤 에이전트의 늦은 `completed`: `finalize_unfinished` 가 디스크 상태가 이미 종단이면 건너뛰고(`:875-877`), `engine.transition` 이 종단 뒤 종단을 거부하는 기존 규칙(#23 리뷰에서 확인한 "conflicting … after terminal")이 그대로다 — 이 PR 이 바꾸지 않았다.
## 3. #30 감시자 `stall_watch.py` (항목 3)
- **잡을 닫는 경로 없음**: 파일 전체에서 `--event` 는 `_notify` 의 `progress` 하나뿐(`:237`), `close`/`error`/`cancelled`/`update_status`/`transition` 호출 0건(grep). `judge()` 는 순수 함수로 `NOTIFY_BLOCKED`/`NOTIFY_IDLE_AND_NUDGE`/`None` 만 낸다. S12 가 소스 수준에서 같은 것을 고정한다. 종료 코드는 0(잡 종단)·2(herdr 불가)뿐 → **갱신 1 준수**.
- **herdr 조회 방식**: `watcher.wait_once`(`agent wait --until … --timeout`)의 서버 소유 배리어를 상태별 `until` 로 돌리고(`:274-284`), idle 뒤에는 30 s 단위로 재판정한다. 폴링·구독이 아니라 배리어 + 판정 시점의 `agent get`/`pane process-info`/`agent read`.
- **blocked**: `pane read --source detection --lines 8` 의 첫 줄(비ASCII 제거, 160자)을 붙여 `stalled:blocked <문구>` 를 에피소드당 1회 발행(`:318-324`), **키 입력 없음** — 갱신 1 의 "자동 수락 없음" 과 일치. `blocked → 다른 상태 → blocked` 면 다시 1회.
- **idle**: `MAM_STALL_IDLE_S`(600) 연속 idle + `agent_pid`(foreground 프로세스, shell 아님) 있음 + 백그라운드 자손 없음(herdr pid → OS 자손 트리에서 어댑터 `resident_children` 과 heartbeat 타이머 제외, `:118-136`) + 입력창 비어 있음(`extract_input_region == ""`) 일 때만 `stalled:idle` + 재주입 **잡당 1회**(`st.nudged`). `state_change_seq` 가 창 안에서 바뀌었으면 창을 다시 시작(R4). 입력 사실이 없는 에이전트는 보류 + 1회 알림. 재주입 문구(`NUDGE`)는 영문·ASCII·1문장이며 "끝났으면 completed/error 발행, 아니면 이어서" 라 오작동 여지가 작다.
- **원격·human 제외**: 래퍼 `start_stall_watch`(`:684-702`)가 `remote:`/`human` 이면 기동하지 않고, `MAM_STALL_WATCH=0` 으로 끌 수 있다(S9/S10).
- **herdr 실패**: `TRANSPORT_ERROR`/`AGENT_NOT_FOUND` 는 "이벤트 없음"으로 읽지 않고 1회 알림 + 백오프, `MAM_STALL_HERDR_FAIL_S`(120) 뒤 rc 2(S8).
- **수명**: 래퍼가 `wait "$sub_pid"` 직후 `stop_stall_watch`(TERM → `os._exit(0)`)로 거둔다(`:385`, `:560`). **M3**: `start_stall_watch` 와 `stop_stall_watch` 사이의 비정상 종료(`set -e` 오류, 검증 훅 실패의 `exit`, 래퍼 SIGKILL)를 덮는 EXIT trap 이 없다. 감시자는 잡 레코드가 종단이 되면 다음 반복(≤ 60 s 배리어)에서 스스로 끝나므로 잡이 닫히는 한 고아 수명은 유계이지만, 잡이 `running` 으로 남는 경우(래퍼 -9)에는 herdr 가 살아 있는 동안 계속 돈다. `.stall.out` 은 `.mam/jobs/<jid>.stall.out` 에 남는다(작고, #20 가드의 `*.json` 감시 밖).
- **실제 시험 A/B 재현성**: 실제 세션에 붙이는 것은 금지라 재현하지 않았다. mock herdr 위의 S2/S3/S7/R1/R2/R4 가 같은 신호 흐름을 실제 `python -m lib_py.stall_watch` 프로세스로 돌린다.
## 4. 경계 기준 — bash 에 들어온 판정 (항목 4)
| 위치 | 내용 | 성격 | 분류 |
|---|---|---|---|
| 래퍼 `:146-151` | `MAM_JOB_TIMEOUT_S` 양의 정수 검증·경고 | 입력 검증(기본값 결정) | 항목 4 이전 대상, 비차단 |
| `finalize_unfinished` `:902`, `:950` | 디스크 heartbeat 유예/broker 재관측 루프가 `$deadline` 에 닿았는지로 `FINALIZE_CLOCK=wall` | **시계 판정** — Python 반환값이 아니라 bash 가 결정 | 항목 4 이전 대상, 비차단(**M1**) |
| `:956-959` | `sub_rc == SUB_RC_WALL_CLOCK → clock=wall`, `phase != inject` 면 `wall_clock_timeout` | rc→라벨 **운반** + inject 예외(판정) | 운반은 기준 부합; inject 예외는 기존 구조 |
| `:684-702` | remote/human 제외, `MAM_STALL_WATCH` | 감시 여부 **판정** | 항목 4 이전 대상, 비차단(**M2**) |
| `cmd_wait` `:1214` | rc 4→2 | 호환 매핑(운반) | 부합 |
| `stop_stall_watch` | rc≠0 이면 로그 꼬리 출력 | 실행부 | 부합 |
"bash 는 라벨을 나르기만 한다"는 주장은 **구독자 rc 경로에 한해** 맞다. 유예 루프의 시계 판정(M1)과 감시 대상 판정(M2)은 bash 의 결정이다. 둘 다 항목 4(래퍼 종결 처리의 Python 이전)에서 옮길 대상이고 PR 본문이 항목 4 를 미룬다고 밝혔으므로 비차단.
## 5. 기존 테스트 조정·새 테스트 (항목 5)
- G-8: idle 0.5/wall 0.5 의 경합을 idle 30/wall 0.5 로 풀어 **어느 시계인지가 결정적**이 됐고 rc 는 `RC_WALL_CLOCK` 으로 단언 — 의도 유지.
- issue5 phase1: 스냅샷이 "숫자 return 금지·이름 비교만" 으로 **강화**됐고 `help-registry.txt` 는 `close` 선택지 갱신. issue18: `contract_args` 에 `--timeout 3600` 이 앞에 오는 것만 갱신. `conftest.py`: mock herdr 에 `force_error_code`·detection 소스·실제 `process-info` 형태·`state_change_seq`·`wait_blocks_s` 추가 — 기존 동작은 기본값으로 유지.
- 새 테스트: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·로그 루트를 `tmp_path` 로, 브로커를 도달 불가로 고정; 감시자는 실제 `python -m lib_py.stall_watch` 프로세스, 래퍼도 실제 스크립트(`--agent human`), herdr 는 mock. 실제 `.mam`·홈·herdr 접촉 없음(§6 실측).
- pane 픽스처 5종: `extract_input_region` 을 5개 idle 캡처에 돌려 **전부 `''`**(S14 의 typing 변형이 비어 있지 않음을 고정). 캡처 자체가 실제 화면과 같은지는 이 리뷰에서 재현할 수 없고(실제 세션 접근 금지), PR 의 "herdr 0.9.1 캡처" 기록에 의존한다.
## 7. 문서 (항목 6)
- delegate-job SKILL.md: `MAM_JOB_TIMEOUT_S`·`MAM_STALL_IDLE_S`/`MAM_STALL_HERDR_FAIL_S`/`MAM_STALL_WATCH=0` 행(`:49-50`), 종료 코드 표에 `2 | Wall clock | error (wall_clock_timeout)` 행(`:100`) 추가 — 코드와 일치. **M5**: 그 행의 "was `deadline_timeout` before v4.7.5" 는 브리프의 병합 버전(**4.8.0**, MINOR)과 어긋난다 — 릴리스 커밋에서 문구 수정.
- loop SKILL.md: `--job-timeout` 행(`:209`)과 "하위 위임 잡" 절의 `MAM_CHILD_WAIT_S` 기본값이 "잡 예산 + 300 s" 로 갱신(`:222`) — 코드와 일치. **M6**: `MAM_WAIT_GRACE_S`(러너의 +300 을 바꾸는 손잡이, `runner.py:608`)는 어느 문서에도 없다.
- 래퍼 usage 의 `--timeout` 기본값 서술 갱신 ✓.
## 8. 설계 합의
**(A) #30 알림 전용 원칙 — 동의.** 근거: heartbeat 는 프로세스 생존만 증명하고(#18 의 절충), herdr 의 `agent_status` 도 "working 으로 보고되는 멈춤"(codex 재접속 사례)을 못 잡는다. 그런 불완전한 신호로 잡을 닫으면 #18 이 세운 "프로토콜 이벤트만이 종결 근거" 계약을 깨고, 정상적인 장기 작업(전체 스위트 2회)까지 닫게 된다. 알림은 사람·루프가 판단할 근거를 주면서 계약을 유지한다. **이 PR 은 그것을 코드로 강제한다**: `stall_watch.py` 의 유일한 발행기 `_notify` 가 `progress` 만 내고, 종결 API(`close`/`error`/`cancelled`/`transition`) 호출이 파일에 없으며, `judge()` 의 반환값이 알림 두 종류뿐이다. S12 가 소스 수준에서 이를 고정하므로 후속 커밋이 조용히 종결 경로를 넣기 어렵다. 한 가지 보강 권고: `judge()` 의 반환 타입을 열거형으로 좁혀 "닫기" 라는 값이 애초에 표현 불가능하게 두면 테스트 없이도 구조가 원칙을 말한다.
**(B) 예산 연장을 #39 로 분리 — 조건부 동의.** 러너가 래퍼를 동기 호출하는 구조에서 "래퍼가 멈춰서 묻고 루프가 정책으로 답한다" 는 원안은 래퍼 안에서 정책을 결정하거나 파일 기반 결정 채널을 추가해야 하고, `state_change_seq` 정체는 정상 장기 작업도 거부한다는 PR 의 관찰에 동의한다 — 이 PR 의 범위에서 분리한 것은 맞다. #39 방식(에이전트가 프로토콜 이벤트로 연장을 요청)의 위험: (1) 에이전트가 반복 요청해 사실상 무한 예산이 되는 것, (2) 원격 잡은 러너 쪽 `timeout_sec` 이 별도 시계라 요청이 닿아도 명령이 먼저 죽을 수 있는 것, (3) 상한이 정책이 아니라 에이전트의 자기 보고에 걸리는 것. **보완책 한 가지**: 연장 요청을 `progress --detail extend_request:<sec>` 로 받되, **결정은 받는 쪽(구독자/루프)이 `MAM_JOB_EXTEND_MAX`(기본 1)·연장 총량 상한·직전 연장 뒤 `state_change_seq` 변화 여부**로 내리고, 승인 시 등록부의 `timeout_sec` 을 CAS 로 갱신해 원격 러너도 같은 값을 읽게 한다 — 즉 요청은 에이전트가, 결정은 Python 이(경계 기준). 상한은 남긴다.
**(C) #40 트리거 — 동의(래퍼 구독자 쪽).** 제어 평면(YAML·SQLite 세션 등록부)은 오케스트레이터 소유(#15/#20/#21)이므로 에이전트 프로세스가 실행하는 `publish_event.py` 에서 세션 ID 를 캡처·고정하는 것은 소유권을 거스른다. 두 후보 중 **래퍼 구독자**가 맞다: 루프 러너의 `wait_for_job` 은 루프 밖 직접 위임(`delegate-job submit --type direct`, 워크트리 creator 의 실제 시나리오)을 지나지 않지만, 래퍼 구독자는 모든 위임(루프 안팎, `MAM_NO_SUBSCRIBER_DAEMON=1` 인 워크트리 creator 포함 — 그 경우 래퍼가 잡별 구독자를 직접 띄운다)의 `completed` 를 받는 단일 지점이기 때문이다. 루프 러너는 그 결과를 읽기만 하면 된다. **순서**: 항목 4 가 래퍼 종결 처리를 Python 으로 옮길 때 그 종결 핸들러에 `capture_active_session_id → auto_pin_if_needed` 를 끼워 넣는 것이 가장 싸다 — 항목 4 에 흡수. 항목 4 보다 먼저 해야 한다면 구독자의 `completed` 처리에 Python 훅으로 넣고 bash 는 손대지 않는다.
**(D) 항목 4 를 미룬 것 — 조건부 동의.** 이 PR 이 항목 4 까지 안았다면 라벨 분리·감시자·종결 처리 이전이 한 리뷰 왕복에 겹쳐 검증 면적이 두 배가 됐을 것이므로, bash 변경을 라벨 운반으로 최소화한 선택은 타당하다. 조건은 §4 표의 두 판정(M1·M2)이 **항목 4 의 명시적 이전 목록**에 올라가는 것이다. 항목 4 에서 Python 으로 옮겨야 할 것: (1) `finalize_unfinished` 전체 — 특히 디스크 heartbeat 유예 루프와 `$deadline` 비교로 시계를 정하는 `FINALIZE_CLOCK`(`:902`, `:950`), `sub_rc → close_req` 매핑과 inject 예외(`:956-959`); 구독자가 "어느 시계인지" 를 이미 rc 로 알고 있으므로 유예 루프 자체를 구독자(또는 그 옆의 Python 종결기)로 옮기면 bash 의 판정이 사라진다. (2) `start_stall_watch` 의 감시 여부 판정(remote/human/`MAM_STALL_WATCH`) — Python 종결기/구독자가 잡 레코드의 `transport`·`agent` 로 결정하고 bash 는 프로세스만 띄운다. (3) `MAM_JOB_TIMEOUT_S` 검증 — 등록부 `register` 가 예산 기본값을 결정하게 하면 래퍼·러너의 이중 검증(M4 의 불일치)도 사라진다. bash 에 남길 것: 감시자·구독자 프로세스 기동/회수(`stop_stall_watch`), 키 주입, 로그 꼬리 출력.
## 6. 테스트 실행과 **차단 결함 N1** (항목 5·7)
### 6-1. 검증 경로에서의 실행
```
tests/test_issue30_stall_watch.py tests/test_issue33_budget.py tests/test_tier1_unit.py tests/test_issue5_phase1_delegation.py
tests/test_issue18_liveness_contract.py tests/test_issue34_job_tree.py tests/test_issue14_delivery_ack.py tests/test_loop_cli.py
→ 3 failed, 222 passed, 48 skipped in 679.80s (0:11:19)
FAILED test_issue33_budget.py::test_b3_a_heartbeating_job_that_spends_its_budget_is_wall_clock_timeout
FAILED test_issue18_liveness_contract.py::test_send_keys_safe_is_confirmed_by_the_job_started_event
FAILED test_issue14_delivery_ack.py::test_wrapper_honours_registry_ack_over_unconfirmed_inject (TimeoutExpired 90 s)
```
- **skip 48 = `test_issue30_stall_watch.py` 전체.** `:39` 의 모듈 수준 `pytestmark = skipif(not linux, "real process trees via /proc")` 가 순수 함수 시험(judge 표·S12·S14·`scoped_env`·추출기 R1/R1b)과 mock herdr 만 쓰는 S9/S10/S11 까지 건너뛴다. 사본에서 그 마크만 지우고 `/proc` 무관 시험을 돌리자 **29 passed** — 이 호스트에서 돌 수 있는 것들이다(M8). `background_children` 의 macOS `ps` 경로는 스위트가 안 타므로 직접 확인했다: 자식 `sleep` 감지, heartbeat 타이머 제외, `resident` 필터, 종료 후 빈 목록 — 모두 정상.
- **b3(heartbeating → wall_clock)**: 단독 재실행에서도 실패. `subscriber.out` 은 `idle timeout (3s, no events)` — 이 시험은 `broker=True` 로 **공개 브로커**를 지나 1 s heartbeat 가 3 s idle 창 안에 도착해야 벽시계(6 s)가 먼저 울린다. 이 호스트의 브로커 지연(~2 s, 스파이크 9 s — 이전 리뷰들의 T-1 부류)에서는 idle 이 먼저 울려 `deadline_timeout` 이 된다. **PR 결함이 아니라 시험 설계의 브로커 의존**(M9)이지만, 이 시험이 #33 의 동기 사례를 고정하는 유일한 자리라 결정적으로 만들 필요가 있다(로컬 events.log 로 heartbeat 를 넣거나 idle 창을 넉넉히).
- **send_keys_safe**: PR 미접촉 파일의 T-1 flake(단독 재실행 통과).
- **issue14 `test_wrapper_honours_registry_ack_over_unconfirmed_inject`: 단독 재실행은 통과했으나 합동 실행에서 래퍼가 멈춰 있었다 → N1.**
### 6-2. N1 (차단) — 래퍼가 stall 감시자를 기다리며 영구 정지, 잡은 `running` 으로 남는다
실측(모두 읽기 전용 관찰; 마지막 한 번만 **내 샌드박스** 프로세스에 TERM 을 보냈다):
| 관찰 | 값 |
|---|---|
| 합동 실행 뒤 호스트에 남은 `lib_py.stall_watch` 프로세스 | 2개 — 하나는 **내** 실행의 잡 `d494fd13`(basetemp `p41-basetemp/test_wrapper_honours_registry_0`), 하나는 **다른 리뷰어** 실행의 같은 시험(`pytest-0/test_wrapper_honours_registry_0`, 잡 `b0e6a171`, 13분 경과) |
| 두 래퍼(bash) 의 상태 | 살아 있고 `sample` 상 메인 스레드가 `__wait4` 에서 대기, **자식은 감시자 하나뿐**(구독자는 이미 종료) |
| 잡 레코드 | 둘 다 `status: running`, `timeout_sec 6` — 벽시계 6 s 가 몇 분이 지나도 닫지 못함 |
| 감시자 로그 | 내 것은 비어 있음(정상 루프), 다른 리뷰어 것은 `input box … not empty; nudge withheld` 가 2 s 간격으로 116줄 — 감시자는 정상 동작 중 |
| 내 감시자에 `kill -TERM` | 즉시 종료 → **래퍼도 즉시 종료** |
| 감시자 단독 TERM 실험(가짜 herdr, 시작 후 0.05/0.15/0.3/0.6/1/2 s) | 전부 종료(rc 143/0) — 감시자 자체는 TERM 을 받는다 |
즉 래퍼는 **감시자 프로세스가 끝나기를 기다리고**, 감시자는 **잡이 종단이 되기를 기다리며**(`stall_watch.py:270-272` 가 유일한 정상 종료 조건), 잡을 종단으로 만드는 것은 그 래퍼다 — 순환 대기. 래퍼가 어느 `wait` 에 있는지는 `sample` 이 bash 심볼을 주지 않아 못 박지 못했다(`stop_stall_watch` 의 `wait "$STALL_PID"` 가 유력하나, 그 앞의 `kill` 이 왜 닿지 않았는지는 재현하지 못했다). 그러나 원인이 무엇이든 **방어가 없다는 것**은 코드로 확정된다:
- `multi-agent-mux-delegate-job:706-719` `stop_stall_watch`: `kill` 뒤 `wait "$STALL_PID"` 가 **무한**이다. 상한도, `kill -9` 승격도 없다. TERM 이 한 번이라도 유실·무시되면 래퍼는 영원히 선다.
- `stall_watch.py:263-312` `run()`: 부모 래퍼의 생존을 보지 않는다(#18 heartbeat 타이머의 `--watch-pid` 같은 결속이 없다). 잡이 종단이 되지 않는 한(래퍼가 죽거나 멈추면 영원히) herdr 가 살아 있는 동안 계속 돈다.
- 결과는 갱신 1 의 "종결은 벽시계·무응답 경로에만" 과 정반대다: 벽시계 경로 자체가 실행되지 못해 잡이 **닫히지 않는다**. 루프 쪽은 `budget+300` 뒤 `wait_for_job` 이 포기하지만 래퍼·감시자·`running` 레코드는 남고, #34 표에는 OPEN 으로 영구히 보인다.
같은 시험이 이 PR 이전(#37·#38 리뷰의 같은 호스트)에는 한 번도 멈춘 적이 없고, 오늘 두 리뷰어의 합동 실행에서 각각 멈췄다. 단독 재실행이 통과하므로 부하·타이밍 의존이지만, 위임 래퍼의 영구 정지는 강도가 높아 **차단**으로 분류한다.
→ **닫는 방법(작음)**: (1) `stop_stall_watch` 를 유계로 — `kill`, 최대 N s(예: 5) 폴링 대기, 남으면 `kill -9`, 그 뒤 `wait`; (2) 감시자에 `--watch-pid <래퍼 pid>` 를 주고 `run()` 루프 머리에서 부모가 사라지면 rc 0 으로 끝나게(heartbeat 타이머와 같은 계약); (3) S9 에 "감시자가 TERM 을 무시해도 래퍼는 N s 안에 돌아온다" 회귀를 추가. (1)만으로도 정지는 사라지고, (2)가 고아를 없앤다.
### 6-3. 수정 없이는 실패하는가
검증 경로 사본에서 PR 이 바꾼 소스 14파일을 `8ea65bb` 판으로 되돌리고(새 모듈 `stall_watch.py` 는 그대로) 새 파일 2개를 돌린 결과 **14 failed / 6 passed / 48 skipped**: B1(env 검증)·B2(예산 전달·대기 상한)·B3·B4(원격 라벨)·B5(#34 표)·R3 가 실패 — #33 회귀는 정정을 정확히 겨냥한다. #30 파일은 Linux 전용 마크로 사본에서도 skip 이라 이 방법으로는 판정할 수 없다(새 모듈이므로 모듈 부재 = 전부 실패, 자명).
### 6-4. 격리
실제 `.mam/jobs`·`.mam/delegate_job_logs` 전후 **차이 0 / 차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `1673818`), 본 체크아웃 `main 8ea65bb`. 실제 세션·herdr 서버 접촉 없음. 단, 위 N1 의 **다른 리뷰어의** 멈춘 래퍼·감시자(pid 11266/12518, `pytest-0/...`)는 내 것이 아니라 그대로 두었다 — 오케스트레이터가 정리할 것.
## 9. 비차단 관찰
| # | 관찰 | 근거 |
|---|---|---|
| M1 | 디스크 heartbeat 유예·broker 재관측 루프의 시계 판정(`FINALIZE_CLOCK=wall`)이 bash 의 결정 | 래퍼 `:902`, `:950`; 항목 4 이전 대상 |
| M2 | 감시 여부(remote/human/`MAM_STALL_WATCH`) 판정이 bash | `:684-702`; 항목 4 이전 대상 |
| M3 | 감시자 수명이 래퍼의 정상 경로에만 묶여 있고 EXIT trap 이 없다 — N1 의 구조적 배경 | `:370-385`, `:706-719` |
| M4 | `MAM_JOB_TIMEOUT_S` 가 잘못된 값일 때 래퍼는 경고, 러너는 무언 | `runner.py:595-600` |
| M5 | delegate-job SKILL.md `:100` "before v4.7.5" — 병합 버전은 4.8.0 | 문서 |
| M6 | `MAM_WAIT_GRACE_S` 미문서 | `runner.py:608` |
| M7 | `MAM_TEST_UNRELIABLE_AGENT` 테스트 훅이 제품 코드에 있음 | `stall_watch.py:228` |
| M8 | `test_issue30_stall_watch.py:39` 모듈 마크가 `/proc` 무관 29건까지 skip | §6-1 |
| M9 | B3 heartbeating 시험이 공개 브로커 지연에 의존(이 호스트 2/2 실패) | §6-1 |
버전 락스텝 bump 부재(`lib.sh:32` `4.7.4`, 4.8.0 예정)는 브리프대로 판정에서 제외한다.
## 10. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T025230Z-a30a6d/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 1673818… / 00d5e03…
# N1: 합동 실행 뒤 남은 감시자와 그 부모 래퍼
ps -eo pid=,ppid=,stat=,etime=,args= | grep '[l]ib_py.stall_watch' # 부모 bash 가 __wait4 에서 대기, 잡 레코드 running
# 감시자에 TERM → 래퍼가 즉시 풀림 (자기 샌드박스 프로세스에만)
# 예산/라벨: --dry-run 으로 timeout_sec 확인, in-process 로 job_budget/job_wait_s/contract_args (§1)
# 되돌린 사본: 소스 14파일을 8ea65bb 판으로 → test_issue33_budget.py → 14 failed / 6 passed
```
[VERDICT: NOT PASS]