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
95 lines
11 KiB
Markdown
95 lines
11 KiB
Markdown
# 🔍 Review Report — PR #41 델타 리뷰 3회차 (job `cc418c3b`)
|
||
|
||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-26
|
||
- **대상 델타**: `af0a2a2..9200562` (1커밋, 4파일 `+144/−31`, `git diff --check` 클린), base `main 8ea65bb` (v4.7.4).
|
||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260926T062809Z-914379/1`. **identity 확인**: `HEAD = 9200562c…b25d`, `HEAD^{tree} = c8425e22…325c` — 브리프와 일치.
|
||
- **2회차 내 판정**: PASS + P1·P2 (job `2cc309ea`; 1회차 N1 은 그때 닫힘). 이번에는 grok N1(TERM 5 s 예산)·codex D1/D2 의 반영을 본다.
|
||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 8ea65bb`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 하네스(`stop_stall_watch` 추출, 대역은 내가 만들고 KILL 로 정리)와 되돌린 사본에서만. 실제 세션에 stall_watch 를 붙이지 않았고 실제 `.mam`·데몬·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음.
|
||
|
||
## 결론 요약
|
||
|
||
**세 정정은 지적을 닫는다.** KILL 기반 stop 은 TERM 을 무시하는 대역에 대해서도 **0.00 s** 에 돌아오고(실측), prompt 앞의 마지막 게이트(`_may_prompt`: 래퍼 생존 + strict 재읽기)와 wait 직후의 `_wrapper_gone` 이 D1/D2 의 창을 닫는다. 남는 마이크로초 경합은 레지스트리 락 없이 닫을 수 없고, 그 결과(닫힌 잡에 한 줄 nudge)는 무해하므로 받아들일 수 있다. 비차단 관찰 2건(Q1·Q2).
|
||
|
||
## 1. grok N1 — KILL 로 바꾼 것 (검토 요청 1)
|
||
|
||
`multi-agent-mux-delegate-job:711-722` `stop_stall_watch`: `kill -0` → **`kill -9`** → `wait`. 하네스 실측(함수 추출):
|
||
|
||
| 대역 | 결과 |
|
||
|---|---|
|
||
| TERM 무시(`trap "" TERM; exec sleep 300`) | rc 0, **0.00 s**, 대역 사망 |
|
||
| 정상 | rc 0, 0.00 s |
|
||
| `STALL_PID` 비어 있음 | 즉시 return 0 |
|
||
| 이미 종료(rc 2) | 즉시, stderr 에 `stall watch for j1 exited rc=2:` + 로그 꼬리(보고 유지) |
|
||
|
||
**타당하다.** 감시자에 정리할 상태가 없다: 파일·락·소켓을 열어 두지 않고(레코드는 매번 열고 닫음), 유일한 부수효과는 두 하위 프로세스 — `publish_event`(알림, `--attempts 1`, 자체 timeout 120 s)와 `herdr agent prompt`(nudge, herdr 자체 `--timeout`) — 뿐이라 KILL 순간 진행 중이던 것은 각자 끝난다. TERM 핸들러도 `os._exit` 하나였으므로 KILL 로 잃는 동작이 없다. 2회차의 5 s 폴링은 래퍼의 close 시계 앞에 붙어 `deadline_timeout` 을 늦추던 부작용이 있었고(grok 지적), 그 예산이 사라졌다. KILL 은 fork-exec 창에서도 삼켜지지 않는다.
|
||
|
||
한 가지 기록(Q1): KILL 순간 비행 중이던 `publish_event` 는 래퍼가 잡을 닫은 **뒤** 에 `progress` 를 남길 수 있다. 종결 뒤 이벤트는 등록부 CAS/종단 규칙이 거르거나 한 줄 알림으로 남을 뿐이라 무해하지만, 래퍼의 디스크 liveness 스캔은 `events.log` 의 **모든** 최근 이벤트를 생존 증거로 세므로 감시자의 `stall_watch:*`/`stalled:*` 알림도 idle 유예를 연장한다(벽시계가 상한이라 무한하지는 않다). "멈춤 알림이 생존 증거가 된다"는 이 긴장은 이 델타가 만든 것이 아니며, 후속에서 스캔이 `progress`+`stall_watch:` 접두 detail 을 제외하면 사라진다.
|
||
|
||
## 2. codex D1/D2 — prompt 앞 게이트 (검토 요청 2·3)
|
||
|
||
- `_dispatch_open(strict=True)`(`stall_watch.py:266-288`): strict 에서는 읽기 오류도 "아니오". `_may_prompt`(`:296-301`) = 래퍼 생존 ∧ strict 재읽기 — `_notify` 가 **돌아온 뒤**, `prompt()` **직전** 에 호출된다(`:408-412`). 발행 rc 는 상태 판단에 쓰지 않는다(rc 0 은 "그 순간의 CAS 통과" 이지 "지금도 열려 있음" 이 아니다 — 맞는 태도).
|
||
- `_wrapper_gone`(`:290-294`)은 루프 머리(`:313`), **매 wait 직후**(`:329`, 어떤 알림·판정보다 먼저), `_may_prompt` 안 — 세 곳. D2 의 "대기 중 부모 소멸 뒤 알림·재주입 1회" 창은 wait 직후 검사로 닫힌다.
|
||
- 남는 경합: `_may_prompt` 가 참을 돌려준 뒤 `self.src.prompt()` 의 `herdr agent prompt` 가 실행되기까지의 마이크로초. 이 안에 잡이 닫히면 닫힌 잡의 pane 에 "끝났으면 completed 를 발행하고 아니면 이어서 하라" 한 줄이 들어간다. 에이전트가 그에 따라 `completed` 를 다시 발행해도 등록부가 종단 뒤 종단을 거부하므로 **상태가 바뀌지 않는다**. 레지스트리 락으로만 닫을 수 있는 창이고 비용이 이득보다 크다 — **받아들일 수 있다.**
|
||
|
||
## 3. 문서
|
||
|
||
SKILL.md `:51-54`: KILL 즉시, 래퍼 소멸 뒤 최대 wait 한 번(≤ 60 s), 소멸을 본 뒤에는 알림·nudge 없음 — 코드와 일치.
|
||
|
||
## 4. 테스트 (검토 요청 4 + 검증 방법)
|
||
|
||
### 4-1. 검증 경로에서의 실행 (브리프의 명령 그대로, pr-41 `.venv`)
|
||
|
||
```
|
||
tests/test_issue30_stall_watch_stop.py tests/test_issue30_stall_watch.py tests/test_issue14_delivery_ack.py
|
||
→ 3 failed, 67 passed, 1 skipped in 511.26s (0:08:31)
|
||
FAILED test_issue30_stall_watch.py::test_s9_the_wrapper_runs_the_watcher_for_the_jobs_life[1]
|
||
FAILED test_issue30_stall_watch.py::test_s10_remote_and_human_jobs_are_not_watched[human-…]
|
||
FAILED test_issue14_delivery_ack.py::test_wrapper_idle_timeout_grace_ignores_pane_busy_token
|
||
SKIPPED test_issue30_stall_watch.py:331 (S6, /proc)
|
||
```
|
||
|
||
- **stop 테스트 11건(기존 7 + 새 4) 전부 통과.**
|
||
- 세 실패는 모두 **공개 브로커 의존**이다. S9[1]·S10[human] 의 실패 지점은 `t18._publish(reg, jid, "started", env).returncode == 0` 이고 stderr 는 `publish failed after 2 attempts: no CONNACK from broker`(4회) — 두 시험은 `_live_broker()` 로 일부러 공개 브로커를 쓴다(`:533-537`). 단독 재실행에서 S9[1]·pane_busy 는 통과했고 S10[human] 만 같은 `no CONNACK` 로 다시 실패했다. 같은 시각 scratch 등록부로 공개 브로커에 `started` 를 세 번 보내자 **1/3 이 21 s 뒤 rc 2** 였다 — 브로커가 이 시간대에 불안정하다는 직접 증거. 이 델타는 human/remote 분기(`start_stall_watch` 의 제외 로직)를 한 줄도 바꾸지 않았고(`git diff … | grep -c 'human\|_is_remote'` = 0), 회차마다 실패하는 케이스가 바뀐다(2회차 S9[0]→R2[False], 3회차 S9[1]→S10). 2회차 P2 그대로.
|
||
- **grok 이 인용한 idle-grace 시험의 분류(검토 요청 1 의 참고)**: 동의한다. `test_issue14` 의 `_base_env` 는 브로커를 고정하지 않아 기본값(공개 브로커)을 쓰고, PR 이 인용한 로그(`subscribe handshake timed out`, 등록 후 ~14 s 무배달 → idle 시계 늦게 시작 → 14 s 의 `completed` 가 열린 잡에 들어감)는 감시자와 무관한 구독자 핸드셰이크 지연이다. 이 시험은 PR #29·#37 리뷰 때도 base 와 동일한 코드에서 같은 방식으로 실패·재실행 통과했었고(이 호스트의 T-1 부류), KILL 로 바뀐 뒤에는 감시자가 close 시계 앞에 넣는 예산 자체가 0 이다. 다만 Q1 처럼 감시자의 알림이 디스크 liveness 로 세어지는 경로는 남아 있어, 그 시험이 감시자 켜진 채로 도는 한 "완전히 무관"은 아니다 — 시험 쪽에서 `MAM_STALL_WATCH=0` 을 주거나(감시자는 그 시험의 대상이 아니다) 스캔이 감시자 알림을 제외하면 깔끔해진다(Q2).
|
||
|
||
### 4-2. 수정 없이는 실패하는가 — 확인함
|
||
|
||
래퍼와 `stall_watch.py` 만 2회차 head(`af0a2a2`)로 되돌린 사본에서 `test_issue30_stall_watch_stop.py` → **5 failed / 6 passed (33.8 s)**: (a) `returns_at_once`(2회차 코드는 5 s 예산으로 took≈6), 그리고 새 4건(발행 중 completed / 재디스패치 / 깨진 레코드 / 대기 중 래퍼 소멸) 이 정확히 실패. 잔류 프로세스 0.
|
||
|
||
### 4-3. 결정성·잔류 (검토 요청 4)
|
||
|
||
새 4건은 in-process(`wait_once` 주입, `_notify` 는 발행 중 다른 행위자가 레코드를 바꾸는 대역, `_alive` 주입)라 브로커·herdr·시계에 의존하지 않고 3회 실행(전체·되돌린 사본·단독)에서 결과가 같았다. 세 실행 뒤 호스트에 `stall_watch --job`/`sleep 300` 잔류 **0**. 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**, 검증 경로 tracked 파일 변화 없음(HEAD `9200562`), 본 체크아웃 `main 8ea65bb`.
|
||
|
||
## 5. 2회차 판정 항목의 처리
|
||
|
||
| # | 2회차 항목 | 이번 델타 |
|
||
|---|---|---|
|
||
| (1회차 N1) 래퍼 영구 정지 | 2회차에 닫힘 | 유지 — 이제 KILL 즉시(0.00 s 실측) |
|
||
| grok N1 | TERM 5 s 예산이 close 시계를 늦춤 | **닫힘** — 예산 제거 |
|
||
| codex D1 | 알림 발행 중 상태 변화에도 prompt | **닫힘** — `_may_prompt`(strict 재읽기 + 래퍼 생존), 테스트 3건 |
|
||
| codex D2 | 대기 중 부모 소멸 뒤 알림·재주입 | **닫힘** — wait 직후 `_wrapper_gone`, 테스트 1건, SKILL.md 문구 |
|
||
| P1 | 부모 소멸 감지 지연의 상태별 상한(정보) | 문서에 "≤ 60 s" 로 명시됨 — 종결 |
|
||
| P2 | S9/R2(이제 S10 도) 의 공개 브로커 의존 | 열림 — 이번 실행에서도 3건 |
|
||
|
||
## 6. 판정
|
||
|
||
세 정정은 지적을 닫고 부작용이 없다(KILL 로 잃는 상태 없음, 게이트가 prompt 의 유일한 경로 앞에 있음). 남는 마이크로초 경합은 결과가 무해해 받아들인다. 버전 락스텝 bump 는 판정에서 제외한다.
|
||
|
||
| # | 비차단 관찰 | 근거 |
|
||
|---|---|---|
|
||
| Q1 | 래퍼의 디스크 liveness 스캔은 `events.log` **마지막 줄의 timestamp** 만 보므로 감시자의 `progress`(`stall_watch:*`/`stalled:*`) 도 생존 증거가 된다 — 멈춤 알림이 idle 유예를 연장하는 긴장(벽시계가 상한) | 래퍼 `:810-825` 의 스캔; 델타 이전부터 |
|
||
| Q2 | `test_issue14` 의 liveness 시험들은 감시자를 끄지 않고 돈다 — 시험 대상이 아니므로 `MAM_STALL_WATCH=0` 을 주는 편이 결정적 | `_base_env` 에 `MAM_STALL_WATCH` 없음 |
|
||
|
||
## 7. 교차검증 가이드
|
||
|
||
```bash
|
||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T062809Z-914379/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}' # 9200562… / c8425e2…
|
||
# stop_stall_watch 추출 → TERM 무시 대역: rc 0, 0.00 s, 대역 사망 (§1)
|
||
# 되돌린 사본(래퍼·stall_watch.py 를 af0a2a2 판으로) → tests/test_issue30_stall_watch_stop.py → 5 failed / 6 passed
|
||
# 브로커 건강: scratch 등록부에 started 3회 발행 → 1/3 rc 2 (no CONNACK) — S9/S10 실패와 같은 원인
|
||
```
|
||
|
||
[VERDICT: PASS]
|