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
This commit is contained in:
1 parent
9200562c91
commit
53f87e4560
32 files changed
+1263
-26
No files matched your search
@@ -0,0 +1,106 @@
|
||||
# 🔍 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]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260926T062809Z-914379",
|
||||
"base_sha": "af0a2a2557768fa316203b177e1b90e9f7283a1a",
|
||||
"head_sha": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
|
||||
"tree_sha": "c8425e2259544c3a34210858f331b0563f15325c",
|
||||
"review_commit": "9200562c914ab4f6e6d8a599cac371a07f99b25d",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "cc418c3b",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-26T07:07:06Z"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# 🔍 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]
|
||||
@@ -0,0 +1,165 @@
|
||||
# 🔍 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]
|
||||
Reference in new issue
Block a user