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
107 lines
12 KiB
Markdown
107 lines
12 KiB
Markdown
# 🔍 Review Report — PR #41 델타 리뷰 2회차 (job `2cc309ea`)
|
||
|
||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
|
||
- **대상 델타**: `1673818..af0a2a2` (1커밋, 5파일 `+406/−12`, `git diff --check` 클린), base `main 8ea65bb` (v4.7.4).
|
||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T052018Z-597343/1`. **identity 확인**: `HEAD = af0a2a25…3a1a`, `HEAD^{tree} = 03106826…6ce8` — 브리프와 일치.
|
||
- **1회차 내 판정**: NOT PASS(N1) + M1~M9 (job `ed0e1492`). 이번에는 N1·codex R1/R2 정정과 내 비차단 항목의 처리를 본다.
|
||
- **주의**: 같은 델타(동일 base·head·tree)에 대한 직전 잡 `ce114c80` 은 내가 검증 중에 오케스트레이터 쪽에서 `cancelled` 되었고 그 검증 경로는 사라졌다. 코드 검토·하네스 실험은 그때 것을(트리가 동일하므로) 그대로 쓰고, 테스트 실행은 **이 잡의 검증 경로에서 다시** 돌렸다(§6).
|
||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(`stop_stall_watch` 함수 추출, 대역 프로세스는 내가 만들고 내가 정리)·in-process 모듈 검사·되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 `.mam`·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 1회차에서 남았던 멈춘 래퍼·감시자는 이번 시작 시점에 더 이상 없었다.
|
||
|
||
## 결론 요약
|
||
|
||
**N1 은 닫혔다.** 래퍼의 `stop_stall_watch` 는 TERM 을 무시하는 감시자에 대해서도 **6 s 안에 돌아오고**(실측), 감시자는 `--watch-pid` 로 래퍼가 사라지면 스스로 끝난다. R2 의 재검사(`_dispatch_open`)는 wait 직후·nudge 직전·루프 머리 세 곳에 있고, 예외 정책도 타당하다. 비차단 관찰 2건(P1·P2).
|
||
|
||
## 1. N1 — 유계 stop (검토 요청 1~2)
|
||
|
||
`multi-agent-mux-delegate-job:710-727` `stop_stall_watch`: `kill -0` → `kill`(TERM) → 0.2 s × 25 폴링 → 남아 있으면 stderr `ignored TERM; killed` + `kill -9` → `wait`. 함수를 그대로 추출해 네 가지 대역에 돌렸다:
|
||
|
||
| 대역 | 결과 |
|
||
|---|---|
|
||
| TERM 을 무시(`trap "" TERM; exec sleep 300`) | **6 s** 에 rc 0 반환, stderr `stall watch for j1 ignored TERM; killed (pid …)`, 대역 사망 |
|
||
| 정상(TERM 에 죽음) | 0 s |
|
||
| `STALL_PID` 비어 있음 | 즉시 return 0 |
|
||
| 이미 종료(rc 2) | 즉시 반환, stderr `stall watch for j1 exited rc=2:` + 로그 꼬리 (기존 보고 유지) |
|
||
|
||
`wait` 가 다시 무한이 될 경로: KILL 뒤의 `wait` 는 SIGKILL 된 자식에 대해 즉시 돌아오고(실측), `kill -0` 실패 분기의 `wait` 는 이미 끝난 자식의 저장된 상태를 돌려준다. 남는 이론적 경우는 자식이 D 상태(커널 내 대기)로 KILL 을 못 받는 것뿐인데 감시자는 파이프·소켓 대기라 해당하지 않는다. **1회차 N1 의 원인 가설(fork-exec 창에서 TERM 이 삼켜짐)** 은 PR 이 래퍼 trace 로 확인했다고 밝혔고, 내 1회차 관찰(래퍼 `__wait4`, 감시자 정상 루프, 수동 TERM 에 즉시 종료)과 정합한다 — 어느 쪽이든 유계 stop 이면 닫힌다.
|
||
|
||
`--watch-pid "$$"`(`:695`): `start_stall_watch` 는 메인 셸의 함수라 `$$` 는 래퍼 pid 다. 감시자 `run()` 루프 머리에서 `_alive(watch_pid)` 가 거짓이면 rc 0(`stall_watch.py:298-303`). `_alive` 실측: 살아 있는 자식 True, **pid 1(권한 없음) True**, reaped/없는 pid False — 권한 오류를 "살아 있음" 으로 보는 규칙이 맞고, `watch_pid` 가 0/None 이면 `if self.args.watch_pid and …` 가 검사를 건너뛴다(`os.kill(0,0)` 은 프로세스 그룹 시그널이라 항상 True 인데, 그 값이 들어올 경로는 없다).
|
||
|
||
## 2. R2 — 재검사 위치와 예외 정책 (검토 요청 4)
|
||
|
||
`_dispatch_open`(`stall_watch.py:266-285`): 레코드 재읽기 → 종단 상태·`dispatch_token` 불일치·`attempt_id` 불일치면 False. 호출 지점 세 곳: 루프 머리(`:296`), **herdr wait 직후**(`:332-334`, 블로킹 대기 중 닫힌/재디스패치된 잡), **nudge 직전**(`:384-386`, `collect()` 가 시간을 쓴 뒤). 충분하다 — prompt 를 보내는 유일한 자리 앞에 두 겹이 있다.
|
||
- 예외: `FileNotFoundError → False`(레코드 삭제 = 잡 없음, 감시 중단이 안전), 그 외 `OSError/ValueError → True`(원자적 갱신 중의 일시 상태를 종결로 오인하지 않음) — 타당하다.
|
||
- blocked 경로는 재검사하지 않는다: 보내는 것이 `progress` 알림뿐이고 prompt 가 없다. 닫힌 잡에 대한 `progress` 는 `publish_event` 의 CAS(토큰)·종단 뒤 거부가 막고, 설령 기록돼도 사람에게 가는 알림 한 줄이라 무해 — 그대로 둬도 된다.
|
||
- `_notify` 가 False(rc ∉ {0,2})면 nudge 를 보내지 않고 `nudged=True` 로 두어 재시도하지 않는다(`:387-391`) — "잡당 1회" 계약 유지.
|
||
|
||
## 3. R1 — `--watch-pid` 만으로 닫은 판단 (검토 요청 5)
|
||
|
||
동의. 래퍼가 TERM/KILL 로 사라지면 그 셸의 어떤 trap 도 돌지 않으므로 trap 합성은 R1 을 닫지 못한다; 자식이 부모 생존을 보는 것이 유일하게 확실한 방법이고 #18 heartbeat 타이머의 `--watch-pid` 와 같은 계약이다. 지연은 최대 herdr wait 한 번(≤ 60 s)이며 문서(`SKILL.md:51-52`)가 그렇게 말한다. 감시자가 대기 중 만든 herdr 자식은 `--timeout` 으로 스스로 끝나고, 새 테스트 (d) 가 "TERM 받은 래퍼 뒤에 감시자가 30 s 안에 rc 0" 을 고정한다. 기존 bootstrap EXIT trap 과의 합성은 항목 4 로 미루는 것이 맞다(래퍼 흐름 변경).
|
||
|
||
## 4. M8 좁힘과 r4 (검토 요청 6)
|
||
|
||
`test_issue30_stall_watch.py:39-40` 이 모듈 마크를 `PROC` 데코레이터로 바꾸고 S6(`/proc` 직접 읽기)에만 붙였다. `box` 기반 시험은 실제 프로세스를 띄우고 `background_children` 의 macOS `ps` 경로를 지나므로 이 호스트에서 **실제 검증**이 된다(1회차에 내가 손으로 확인한 경로). r4 의 stub 시계 `monotonic → time`: stub 프로세스와 판정 프로세스가 서로 다른 프로세스라 macOS 의 프로세스별 monotonic 기준점 차이를 피하는 것이 맞다(codex 지적 타당).
|
||
|
||
## 5. 1회차 내 항목의 처리
|
||
|
||
| # | 1회차 항목 | 이번 델타 |
|
||
|---|---|---|
|
||
| N1 | 래퍼가 감시자를 기다리며 영구 정지 | **닫힘** — 유계 stop + `--watch-pid` + 테스트 (a)(b)(d) |
|
||
| M1 | 유예 루프의 시계 판정이 bash | 열림 → #17 항목 4 목록(PR 명시) |
|
||
| M2 | 감시 여부 판정이 bash | 열림 → 항목 4 |
|
||
| M3 | 감시자 수명이 래퍼 정상 경로에만 묶임 | **닫힘** — `--watch-pid` |
|
||
| M4 | `MAM_JOB_TIMEOUT_S` 오류 시 러너 무언 | 열림 → 항목 4 |
|
||
| M5 | "before v4.7.5" | **닫힘** — v4.8.0 |
|
||
| M6 | `MAM_WAIT_GRACE_S` 미문서 | 열림 → 항목 4 목록(PR 명시) |
|
||
| M7 | `MAM_TEST_UNRELIABLE_AGENT` 훅 | 열림(후속, 사유 타당) |
|
||
| M8 | 모듈 전체 Linux skip | **닫힘** — S6 만 |
|
||
| M9 | B3 의 공개 브로커 의존 | 열림(후속; "started 자체가 안 닿는 경우" 라는 진단에 동의 — 로컬 events.log 로 heartbeat 를 넣는 방식이 남은 선택지) |
|
||
|
||
## 6. 테스트 (검토 요청 3 + 검증 방법)
|
||
|
||
### 6-1. 이 잡의 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 `.venv` 인터프리터)
|
||
|
||
```
|
||
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py
|
||
→ 1 failed, 132 passed, 1 skipped in 589.33s (0:09:49)
|
||
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[0]
|
||
SKIPPED test_issue30_stall_watch.py:331 (S6, real process trees via /proc)
|
||
```
|
||
|
||
- **새 stop 테스트 7건 전부 통과**, `test_issue30` 는 M8 좁힘대로 S6 하나만 skip 이고 나머지(box 기반 S1–S5·S7·S8·S13, R1–R4 포함)가 이 호스트에서 **실제로 돈다**. 1회차에서 멈췄던 `test_issue14::test_wrapper_honours_registry_ack_over_unconfirmed_inject` 는 합동·단독 모두 통과 — **N1 재현 없음**.
|
||
- **S9[0] 실패는 공개 브로커 flake** 다: 단언은 `t18._publish(... "started").returncode == 0` 이고 stderr 는 `publish failed after 2 attempts: no CONNACK from broker`. S9 는 `_live_broker()` 로 **일부러** 공개 브로커를 쓰며(래퍼 구독자가 와이어를 봐야 하므로), 진짜 도달 불가 브로커(127.0.0.1:9)에 대해 `publish_event` 는 6/6 회 rc 2 를 돌려준다 — 즉 0 을 기대하는 이 단언은 브로커가 응답할 때만 성립한다. 같은 세 시험(S9[0]·R2[True]·issue14 pane_busy)을 단독으로 다시 돌리자 그 셋은 통과하고 이번엔 **R2[False]** 가 같은 `no CONNACK` 로 실패했다 — 어느 케이스가 걸리는지가 브로커 응답 여부로 바뀌는 전형적 T-1 이다. `publish_event` 는 이 델타가 건드리지 않았다(P2).
|
||
- 같은 트리(1673818..af0a2a2 는 동일 tree `0310682`)에 대한 직전 잡 `ce114c80` 의 실행(취소 전 완료)도 130 passed / 3 failed(S9[0]·R2[True]·issue14 pane_busy — 전부 위 부류) / 1 skipped 로 같은 그림이었다.
|
||
|
||
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||
|
||
사본에서 래퍼와 `stall_watch.py` 만 `1673818` 판으로 되돌리고 `test_issue30_stall_watch_stop.py` 를 돌린 결과 **7 failed / 0 passed (134 s)** — (a)(b)(c)(d) 는 `TimeoutExpired`, (e)(f)(g) 는 AssertionError. 되돌린 실행이 남긴 프로세스는 없었다(대역·shim 은 테스트가 정리).
|
||
|
||
### 6-3. 결정성·잔류 (검토 요청 3)
|
||
|
||
- (a) 대역은 `kill -9` 로, (b) shim 은 래퍼가, (c)(d) 의 `sleep 300`/감시자는 테스트의 `finally` 가 죽인다 — 두 번의 전체 실행과 되돌린 실행 뒤 **`stall_watch --job`/`sleep 300` 잔류 0**(호스트에 남은 `lib_py` 프로세스는 오케스트레이터의 루프 러너뿐).
|
||
- 시간 상한은 (a) 4–12 s, (b) 40 s, (c)(d) 30 s 로 stop 의 5 s 폴링과 감시자의 최대 60 s wait 를 넉넉히 감싼다. 이번 실행에서 stop 테스트들은 상위 8 느린 시험에 들지 않았다.
|
||
- 실제 `.mam/jobs` 전후: 이 잡 자신의 heartbeat/레코드 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `af0a2a2`), 본 체크아웃 `main 8ea65bb`.
|
||
|
||
## 7. 판정
|
||
|
||
N1(래퍼 영구 정지)은 유계 stop(실측 6 s)과 `--watch-pid`(부모 소멸 시 자진 종료)로 **양쪽에서** 닫혔고, R2 는 prompt 앞 두 겹 재검사로 닫혔으며, R1 은 `--watch-pid` 만으로 닫은 판단이 맞다. 1회차 판정을 뒤집을 요소는 없고, 델타 밖의 남은 항목(M1/M2/M4/M6 → 항목 4, M7·M9 후속)은 PR 이 명시했다. 버전 락스텝 bump 는 브리프대로 판정에서 제외한다.
|
||
|
||
| # | 비차단 관찰 | 근거 |
|
||
|---|---|---|
|
||
| P1 | 감시자가 부모 소멸을 알아채는 지연이 최대 herdr wait 한 번(≤ 60 s)이라 문서와 일치하지만, idle 상태에서는 wait 가 30 s 단위라 실제 지연은 그보다 짧고 `blocked`/`working` 대기(60 s)에서 가장 길다 | `stall_watch.py:276-283`; 정보 |
|
||
| P2 | S9/R2 가 공개 브로커에 의존해 `no CONNACK` 로 간헐 실패한다(이 호스트 3회 실행 중 매번 1–3건). `_live_broker()` 가 설계라면 rc 0 대신 로컬 `events.log` 의 `started` 줄로 단언하거나 rc 2 를 허용하는 편이 결정적 | `test_issue30_stall_watch.py` S9 `_publish(...).returncode == 0`; T-1 부류 |
|
||
|
||
## 8. 교차검증 가이드
|
||
|
||
```bash
|
||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1
|
||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-41/.venv/bin/python
|
||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # af0a2a2… / 0310682…
|
||
# stop_stall_watch 만 추출해 TERM 무시 대역에 → 6 s 안에 "ignored TERM; killed" 후 반환 (§1 표)
|
||
# _alive: 살아있는 자식 True / pid 1 True(권한) / reaped False (in-process)
|
||
# 되돌린 사본: 래퍼·stall_watch.py 를 1673818 판으로 → tests/test_issue30_stall_watch_stop.py → 7 failed
|
||
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
|
||
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py tests/test_tier1_unit.py)
|
||
```
|
||
|
||
[VERDICT: PASS]
|