Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-48b1b0f8.md
T
GodopuandClaude Fable 5.1 5611859930 docs(reports): archive v4.5.0 PASS review reports (Issue #18)
Final HEAD 263d6bf: claude, agy and grok PASS (delta review of the
lifecycle-lock commits on top of their ab842f1 PASS), codex PASS (its own
round after F1/F2). Full suite at 263d6bf: 906 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-14 01:30:28 +09:00

133 lines
10 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 — Job 48b1b0f8 (Issue #18 델타 리뷰, reviewed HEAD `263d6bf`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-14
- **대상**: `git diff ab842f1...HEAD` 2커밋, 4파일 +273/−10.
- `2b1d590`: 잡별 lifecycle lock, 구현 세션 커밋
- `263d6bf`: codex F1·F2 교정, creator 커밋
- 전체 브랜치는 내가 `ab842f1` 에 PASS 한 범위다(`98215f55`).
- 브리프의 "변경분" 은 작업 트리의 추적되지 않은 codex 리포트 파일 하나뿐이었다. 그래서 델타는 git 으로 직접 확인했다.
- HEAD 는 이 잡 내내 `263d6bf` 이다(`completed` 발행 명령에서 다시 확인).
- **방법**:
- 델타를 정독했다.
- 임시 레지스트리에서 실제 타이머 프로세스로 독립 실험 4건을 했다(브로커 없음, 닫힌 포트·블랙홀 주소).
- 선택 테스트 9파일을 실행하고 정적 검사를 했다.
- 이 잡은 `started --heartbeat 30` 으로 시작했다. 코드는 수정하지 않았다.
- **안전 규칙 준수**:
- 실제 `.mam/jobs` 레코드는 수정하지 않았다(실행 전후 비교).
- monitor 데몬과 `.mam/monitor.lock` 은 건드리지 않았다. purge 는 실행하지 않았다.
- 실험 타이머 잔여는 0 이다.
## 0. 경위
| 리뷰 | HEAD | 판정 | 요지 |
|---|---|---|---|
| claude `98215f55`·agy·grok | `ab842f1` | PASS | — |
| codex (4회차) | `ab842f1` | NOT PASS | 동시 `start_timer` 2건이 둘 다 spawn 하고 하나만 추적됨 → `2b1d590` |
| codex `d1eb9f50` | `2b1d590` | NOT PASS | **F1**: 종료 중인 타이머가 drain 중인 부모가 쥔 lock 을 기다리는 유한 교착(항상 SIGKILL 로 끝남). **F2**: pidfile 기록 실패 뒤 추적되지 않는 타이머가 남음 → `263d6bf` |
| codex `bad3ef4b` | `263d6bf` | PASS | — |
## 1. 검증 항목
### (1) lifecycle lock 과 레지스트리·seq lock 의 순서, 교착 가능성
- **구조**: lifecycle lock(`heartbeat.py:187-209`, `<job>.heartbeat.lock`)은 레지스트리·seq lock 과 **별개의 flock** 이다. 잡는 곳은 세 군데뿐이다.
- 터미널 `stop_timer`(`:221`)
- `start_timer`(`:312`)
- 종료하는 타이머의 정리(`:473`, **non-blocking**)
- **lifecycle lock 안에서 하는 일**:
- `_stop_timer_locked` 의 drain 은 레지스트리 lock 을 잡지 않는다.
- `load_job` 은 lock 없는 읽기다(`mqtt_common.py:393-399`). `barrier_blocks` 도 파일 읽기뿐이다.
- 그 뒤 spawn 과 pidfile 기록을 한다.
- **레지스트리 lock 을 잡는 곳**: `raise_barrier`(`heartbeat.py:270`)와 `next_seq` 뿐이다. 둘 다 lifecycle lock 을 잡지 않는다.
- 터미널 publish 는 `raise_barrier`(레지스트리 lock 해제) → `stop_timer`(lifecycle) 순으로 **중첩 없이 차례로** 잡는다.
- 따라서 역순 중첩이 없고, lock 순서 교착은 불가능하다.
- **drain 중 in-flight beat**: beat 는 seq lock 만 필요하다. 그 lock 은 drain 하는 쪽이 쥐고 있지 않으므로 beat 는 끝까지 갈 수 있다.
- **F1 교정**: 종료하는 타이머는 lock 이 바쁘면 기다리지 않고 정리를 소유 연산에 맡긴다. 소유 연산(start·stop)은 모두 `_stop_timer_locked` 로 시작하며, 그 안에서 pidfile 을 지운다.
- **실험 E1 — 유휴 타이머 정지** (interval 60, 2 s 대기 뒤 `stop_timer(drain=5)`):
- **0.17 s** 에 반환했고, pidfile 이 제거되었으며, "did not drain" 경고가 없었다.
- codex 가 `2b1d590` 에서 잰 값은 2.09 s, `-9` 였다. 교착이 해소되었다.
- **실험 E4 — 와이어에 걸린 beat 가 있는 drain** (브로커 `10.255.255.1` 블랙홀, interval 1):
- `stop_timer(drain=1)` 가 **1.12 s** 에 SIGKILL 로 끝났다. 이 경우의 "did not drain" 경고 1건은 정당하다.
- 직후 `next_seq` 는 **0.001 s** 에 반환했다(seq lock 은 비어 있음). pidfile 은 제거되었다.
- drain 과 seq lock 사이에 교착이 없다.
### (2) 동시 start, barrier 뒤 start, pidfile 기록 실패
- **실험 E2 — 동시 `start_timer` 5건**(스레드 barrier 로 동시 출발):
- 오류 0, 살아남은 타이머 **정확히 1개**, pidfile 이 그 타이머를 가리킨다.
- `stop_timer` 뒤 생존 0, pidfile 없음.
- **실험 E3 — `start_timer` 와 터미널 `completed` publish 경쟁, 8라운드**(시작 간격 0–14 ms 로 변화):
- **위반 0/8**: 매 라운드 레코드 `completed`, 생존 타이머 0, pidfile 없음, events.log 에 `completed` 뒤 이벤트 0.
- 1라운드는 start 가 먼저 arm 해서 터미널이 drain 했다. 7라운드는 barrier·터미널을 보고 **arm 하지 않았다**.
- **pidfile 기록 실패 (F2)**(`:343-364`): spawn 성공 뒤 pidfile 기록이 실패하면 lock 을 쥔 채 그 `Popen` 을 kill·wait 한다. 그리고 이 시도의 부분 pidfile 만 지운 뒤 예외를 다시 던진다.
- 시험 `test_a_timer_whose_pid_cannot_be_recorded_is_killed_at_once` 가 SIGKILL 종료, pidfile 없음, lock 재사용 가능을 단정한다.
### (3) 예외 경로에서 lock 해제와 pidfile 일관성
- `lifecycle_lock` 은 `try/finally` 로 `LOCK_UN` 하고, `with open(...)` 가 fd 를 닫으므로 예외에서도 해제된다.
- non-blocking 이 바쁘면 `False` 를 내주고, 호출자는 소유 상태를 건드리지 않는다(`:473-478`).
- **spawn 전 실패**(`Popen` 오류): pidfile 을 쓰지 않고 lock 은 해제된다. 시험 `…cannot_be_spawned_leaves_nothing_behind` 가 확인한다.
- **spawn 후 실패**: 이전 소유자의 pidfile 은 이미 `_stop_timer_locked` 가 지웠다. 따라서 남은 내용은 이번 시도의 것뿐이고, pid 접두 비교로 지우는 판단이 옳다.
- P3:
- `lifecycle_lock` 은 `os.makedirs(registry_dir)` 를 한다. 그래서 잘못된 레지스트리 경로로 터미널 publish 를 하면 디렉터리가 생긴다.
- 모든 터미널 publish 가 잡마다 `<job>.heartbeat.lock` 을 남긴다(기존 `.heartbeat.barrier` 와 같은 성질). `.json` 이 아니므로 잡 목록과 #15 가드에는 영향이 없다. 레지스트리 정리 정책에서 함께 다룰 것을 권한다.
### (4) 새 회귀 시험의 결정성과 프로세스 정리
| 시험 | 판단 |
|---|---|
| `test_concurrent_start_timer_calls_leave_exactly_one_tracked_owner` | 스레드 2개로 순서를 강제하지 않는다(codex 도 지적). 위의 5건 동시 실험이 보완한다. `finally` 에서 두 pid 를 SIGKILL 한다 |
| `test_start_timer_after_the_terminal_barrier_does_not_arm` | 결정적(프로세스 없음) |
| `test_an_idle_timer_stops_gracefully_well_before_the_drain_deadline` | 2 s 대기로 TERM 핸들러 설치 뒤를 노린다. 과부하로 핸들러 설치 전에 TERM 이 가도 기본 동작으로 곧 끝나므로 `< 3 s` 단정은 유지된다. `finally` 에서 정리한다 |
| `test_a_timer_whose_pid_cannot_be_recorded_is_killed_at_once` | `builtins.open` 을 잠깐 패치하고 `finally` 에서 복구한다. spawn 된 프로세스는 `finally` 에서 kill·wait 한다 |
| `test_a_timer_that_cannot_be_spawned_leaves_nothing_behind` | 없는 python 경로를 쓴다. 두 번째 타이머는 `stop_timer`·SIGKILL 로 정리한다 |
- 실행 결과는 §2 에 있다.
### (5) 문서·수치·락스텝
- MESSAGING §2.5 heartbeat 행이 코드와 일치한다: "lifecycle lock(`<registry>/<job>.heartbeat.lock`, seq lock 과 별개) — 끝난 attempt 는 arm 하지 않음, 종료 타이머는 lock 을 기다리지 않음, pid 기록 실패 시 즉시 종료". VERSIONS 항목 1 도 같다.
- VERSIONS 품질 절 "신규 테스트 31개"(`VERSIONS.md:54`)는 `grep -c '^def test_'` 결과 31 과 같다.
- 4.5.0 락스텝 유지: `verify_framework_version` OK, `SKILL.md` 8개 4.5.0.
- `git diff --check 139bdd4...HEAD` 클린. `py_compile`(`heartbeat.py`, 시험) 통과.
## 2. 테스트 실행 결과
| 실행 | 결과 |
|---|---|
| **선택 9파일** (issue18 31, issue14, issue6 fail_closed·turn_handshake, phase1, phase2a, tier1, tier2, version) @ `263d6bf`(전후 동일) | **355 passed, 0 failed, 742.30 s**, skipped 0. 브로커 의존 AC1 22.09 s, AC3 18.92 s, pane-busy 17.93 s, 벽시계 30.12 s |
| 독립 실험 E1–E4 | 모두 기대대로(§1). 실험 타이머 잔여 0 |
| creator `31a24383` (3파일) | 100 passed |
- **누수 (실행 전후)**:
- 실제 monitor 데몬 수가 같다(3개, 실행 전후 동일 PID).
- pytest 경로의 heartbeat 타이머·구독자·원격 러너는 0 이다.
- 실제 `.mam/jobs` 추가는 0 이다.
## 3. 비차단 권고
1. (이월, T-1) 브로커 의존 시간 임계 시험의 여유를 넓힌다: AC1·AC3·AC5 의 K×N, issue14 벽시계·pane-busy, send_keys `< 11`.
2. 잡마다 남는 `.heartbeat.lock`·`.heartbeat.barrier` 파일의 정리 정책을 정한다. `lifecycle_lock` 의 `makedirs` 부작용도 함께 본다.
3. (이월) AC5 는 단일 원격 잡 시뮬레이션과 러너 라벨 단위 시험까지만 검증되었다. 루프 종단 수렴은 후속 E2E 로 남는다.
## 4. GM 참고
- **추적되지 않은 파일**: 작업 트리에 `.agents/reports/planner-reviewer-codex-01/report-bad3ef4b.md` 가 있다(codex 리포트 사본). 브리프의 "변경분" 이 이것 하나로 채워졌다. 나는 건드리지 않았다.
- **커밋 주체**: `2b1d590` 은 구현 세션(`…01VCgu9Fa9iT7TbJUSzdq4Ff`), `263d6bf` 는 creator(`…01HCGpJXhSZasJLU38ksDghe`)의 커밋이다.
- **브랜치 ref**: 11커밋 모두 로컬 `main` 에 있다(`origin/main` 보다 11 앞, 미푸시). `fix/issue-18` 은 `139bdd4` 다. 병합 전에 ref 정리가 필요하다.
## 5. 종합
- `2b1d590` 의 잡별 lifecycle lock 은 레지스트리·seq lock 과 중첩 없이 분리되어 있어 교착이 없다.
- `263d6bf` 의 non-blocking 종료 정리(F1)와 spawn 후 pid 기록 실패 시 종료(F2)가 정확하다.
- 독립 실험으로 확인했다:
- 유휴 정지 0.17 s
- 동시 start 5건에서 추적 소유자 1개
- start 와 터미널 경쟁 8라운드에서 위반 0
- 걸린 beat 가 있어도 seq lock 이 즉시 비어 있음
- 새 시험들이 이 경로들을 고정한다. 선택 스위트 355건이 모두 통과했다.
- 문서·수치(31)·4.5.0 락스텝이 정합한다.
- 남은 것은 비차단 권고뿐이며 설계 재검토는 필요 없다.
[VERDICT: PASS]