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
24 KiB
🔍 Review Report — PR #41 병합 전 독립 리뷰 + 설계 합의 (job ed0e1492)
- Reviewer:
planner-reviewer-claude-01(role: planner,reviewer) · 2026-09-26 - 대상: PR #41
fix/issue-30-33@1673818, basemain 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-61RC_COMPLETED/ERROR/NO_EVENT/BROKER_UNAVAILABLE/WALL_CLOCK = 0/1/2/3/4, 래퍼:144SUB_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: 그 행의 "wasdeadline_timeoutbefore 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의 macOSps경로는 스위트가 안 타므로 직접 확인했다: 자식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-719stop_stall_watch:kill뒤wait "$STALL_PID"가 무한이다. 상한도,kill -9승격도 없다. TERM 이 한 번이라도 유실·무시되면 래퍼는 영원히 선다.stall_watch.py:263-312run(): 부모 래퍼의 생존을 보지 않는다(#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. 교차검증 가이드
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]