Compare commits
262
Commits
80a5614f7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1e2e87de | ||
|
|
342a9a4edf | ||
|
|
5f6065344f | ||
|
|
a47a4683f3 | ||
|
|
8b829647f4 | ||
|
|
0fc162e8cd | ||
|
|
a6fb39ba9f | ||
|
|
9410913da9 | ||
|
|
bd61ef532f | ||
|
|
015075077c | ||
|
|
263a72483a | ||
|
|
5cd72d2df3 | ||
|
|
e4c7c8fe9b | ||
|
|
9c21fa4e4f | ||
|
|
50e2998a20 | ||
|
|
4243da507c | ||
|
|
29cc4fd904 | ||
|
|
04e32212f7 | ||
|
|
bb496fde78 | ||
|
|
0461a632eb | ||
|
|
0454fe968b | ||
|
|
1614af5b67 | ||
|
|
7cac6f613e | ||
|
|
8f64174b6b | ||
|
|
ede9c62be3 | ||
|
|
a907cc5294 | ||
|
|
d115bf7156 | ||
|
|
c747374aa6 | ||
|
|
8c9a68cf1c | ||
|
|
e594d2b650 | ||
|
|
fa93cc5622 | ||
|
|
3a2219454e | ||
|
|
b8a30d5b93 | ||
|
|
ead490bddc | ||
|
|
dc886ecd6b | ||
|
|
0d5f10a546 | ||
|
|
84af709bb3 | ||
|
|
feccfd5e03 | ||
|
|
ff8b381520 | ||
|
|
38ea4f3b8e | ||
|
|
b0eccc5301 | ||
|
|
3ce5d5fdc1 | ||
|
|
cffe279eb0 | ||
|
|
6a37c0019b | ||
|
|
6a4e385a78 | ||
|
|
d17cd80bd9 | ||
|
|
7803d47026 | ||
|
|
463817ee34 | ||
|
|
614ab1420f | ||
|
|
ac5f1154c7 | ||
|
|
14d7a46181 | ||
|
|
4d49abb371 | ||
|
|
1d7b7dab58 | ||
|
|
cb2c84ccd7 | ||
|
|
0e05b1b677 | ||
|
|
cb5f439cce | ||
|
|
6b4b96fa18 | ||
|
|
f0d1c000f9 | ||
|
|
a311c23303 | ||
|
|
74333f87d0 | ||
|
|
6fd417a7b7 | ||
|
|
de6f3fc81f | ||
|
|
2a8d73825b | ||
|
|
81b6c2ea6b | ||
|
|
dd292c12cb | ||
|
|
c0248b88be | ||
|
|
60779ba1f8 | ||
|
|
e156179822 | ||
|
|
af8ed397f6 | ||
|
|
f943b0db30 | ||
|
|
6e0361cf43 | ||
|
|
bd09c6a133 | ||
|
|
26fb1c4130 | ||
|
|
6f4f40a4ad | ||
|
|
f0af98971e | ||
|
|
872d5f12fb | ||
|
|
57830cf00a | ||
|
|
f542596975 | ||
|
|
8a4f651bdf | ||
|
|
8edb8d6f0f | ||
|
|
6328e67469 | ||
|
|
b3461377ae | ||
|
|
ae98cf18c4 | ||
|
|
48cda51d85 | ||
|
|
366da3b3d6 | ||
|
|
9ccd3c1835 | ||
|
|
26e2c43753 | ||
|
|
7cf728e9f8 | ||
|
|
a7cb90e63a | ||
|
|
1c598bb546 | ||
|
|
151af99765 | ||
|
|
75c004222c | ||
|
|
63d816468e | ||
|
|
c57e7667aa | ||
|
|
c982616188 | ||
|
|
f299034d8d | ||
|
|
6d182b392c | ||
|
|
642a53b716 | ||
|
|
813cfd38d1 | ||
|
|
a159a790df | ||
|
|
cbd87734c2 | ||
|
|
b7a857d12f | ||
|
|
b8bed8124e | ||
|
|
e32c957464 | ||
|
|
121eab71d4 | ||
|
|
3a30d7da89 | ||
|
|
42e2a7ea9b | ||
|
|
e3bcdee834 | ||
|
|
62da805668 | ||
|
|
ae2813773c | ||
|
|
423abc7844 | ||
|
|
20b1014717 | ||
|
|
2ccf43911c | ||
|
|
50c8da8269 | ||
|
|
db7f4cab9c | ||
|
|
325b056057 | ||
|
|
525df2bb58 | ||
|
|
8b0b4a07dc | ||
|
|
ffe8665b27 | ||
|
|
21f0c12b9e | ||
|
|
efd27def47 | ||
|
|
a9ffedac36 | ||
|
|
320aa9f7a0 | ||
|
|
6c99fedf40 | ||
|
|
d50610c1ab | ||
|
|
7844ae2c5f | ||
|
|
30c25f193c | ||
|
|
9cbe08e8de | ||
|
|
9e78ff3ba3 | ||
|
|
3705bc72a8 | ||
|
|
05bffc6475 | ||
|
|
630f04bfdc | ||
|
|
e9efabf74b | ||
|
|
c29c5b2142 | ||
|
|
40293841e7 | ||
|
|
42e51735ba | ||
|
|
ca688dcef4 | ||
|
|
a93c9bf292 | ||
|
|
97c2946ee0 | ||
|
|
e0181ea9be | ||
|
|
ac48cb7861 | ||
|
|
8b47cc76ee | ||
|
|
d198dd50aa | ||
|
|
6b00b6e06f | ||
|
|
cd82ab6e72 | ||
|
|
65b78b6ec4 | ||
|
|
541c3febe8 | ||
|
|
8241bd53b1 | ||
|
|
c90a0afa96 | ||
|
|
d6cb01987c | ||
|
|
a670ef53ee | ||
|
|
f19f94099f | ||
|
|
25c99704f2 | ||
|
|
56d0bfda54 | ||
|
|
8fde39be33 | ||
|
|
4c86107846 | ||
|
|
518bedac5d | ||
|
|
c5cebe6c32 | ||
|
|
66d1490230 | ||
|
|
6947d3c785 | ||
|
|
3b3ce307df | ||
|
|
6bf9f92462 | ||
|
|
63d9c205ff | ||
|
|
7209750ac7 | ||
|
|
bf8a583afb | ||
|
|
ab93776d6d | ||
|
|
e8d9151226 | ||
|
|
36f0b8dfd6 | ||
|
|
35935f9e8a | ||
|
|
7c544c06ab | ||
|
|
811b080966 | ||
|
|
ce91cc11b9 | ||
|
|
eedcebcc12 | ||
|
|
feceddf49f | ||
|
|
fb1380fdc1 | ||
|
|
933af344c2 | ||
|
|
ed73f67a4f | ||
|
|
a640b0f072 | ||
|
|
c5c7eff6f7 | ||
|
|
65dc7fde82 | ||
|
|
150b4937f8 | ||
|
|
e1ed2708d8 | ||
|
|
6af4a796b1 | ||
|
|
44999ff2fb | ||
|
|
17eba0904e | ||
|
|
8d26106195 | ||
|
|
e6a46cd7c4 | ||
|
|
7670b41afd | ||
|
|
45d1621c93 | ||
|
|
3563a6fc17 | ||
|
|
75f364e4b4 | ||
|
|
95eabc693f | ||
|
|
1e92ddc563 | ||
|
|
c252ac4d54 | ||
|
|
fb1b93c665 | ||
|
|
8b046390be | ||
|
|
71ea8095c5 | ||
|
|
5edf913d22 | ||
|
|
75936f11c6 | ||
|
|
0051fd6c3b | ||
|
|
2e90c809d3 | ||
|
|
0eec795cd1 | ||
|
|
b58edace11 | ||
|
|
fb006374d6 | ||
|
|
34241d7a65 | ||
|
|
2826e709ae | ||
|
|
80dc2eb45e | ||
|
|
f1331d36ce | ||
|
|
e1eb22bd59 | ||
|
|
53f87e4560 | ||
|
|
9200562c91 | ||
|
|
af0a2a2557 | ||
|
|
1673818bb5 | ||
|
|
9ec0569401 | ||
|
|
bf0f5c1014 | ||
|
|
47e42744e7 | ||
|
|
622c1f8a9d | ||
|
|
ec9586bb9b | ||
|
|
8ea65bb238 | ||
|
|
22586d2a01 | ||
|
|
e80186f560 | ||
|
|
8a9101ed30 | ||
|
|
ed293dcef4 | ||
|
|
1d4f338b4c | ||
|
|
3fdbb626de | ||
|
|
8690ef4bd2 | ||
|
|
30c8c7ebab | ||
|
|
ec663a93c8 | ||
|
|
7a4a0c2091 | ||
|
|
c3e6a7e96d | ||
|
|
708262a598 | ||
|
|
59be1c8920 | ||
|
|
f26952ccc3 | ||
|
|
14d11bf062 | ||
|
|
f5cadd98c0 | ||
|
|
c77806b1b1 | ||
|
|
68a5d31381 | ||
|
|
1debc7d924 | ||
|
|
9fb037c084 | ||
|
|
05b0ea4762 | ||
|
|
f4ba205c9f | ||
|
|
087eacaf21 | ||
|
|
4805fd4511 | ||
|
|
1667fda8dd | ||
|
|
f7cee00dec | ||
|
|
10a678c010 | ||
|
|
0dfbb2cc8a | ||
|
|
ce802d21c8 | ||
|
|
33b91fe0c8 | ||
|
|
2cd20423ac | ||
|
|
123dca47bb | ||
|
|
5ba04a27ca | ||
|
|
3bcaa39ab0 | ||
|
|
1e78dae6eb | ||
|
|
6b7198f73c | ||
|
|
1e826515cc | ||
|
|
5fa592347c | ||
|
|
a334083760 | ||
|
|
e551e3baf1 | ||
|
|
a28711cccf | ||
|
|
6fc9194f89 | ||
|
|
cf5d1142e9 |
No files matched your search
@@ -151,6 +151,7 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
|
||||
- **작업 위임**:
|
||||
- *수동 경로*: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 실행 명령만 전달하십시오.
|
||||
- *자동 경로*: 자동화 잡 런너(`multi-agent-mux-delegate-job submit`)는 잡 등록 시 `.mam/jobs/<job_id>/brief.md` 디렉터리에 지시서를 자동 집필하고 단일 라인 포인터 프롬프트만 에이전트 세션에 인가합니다.
|
||||
- *하위 위임*: 자기 잡의 일부를 다시 위임하는 에이전트는 `submit` 에 `--parent-job <자기 job_id>` 를 붙입니다. 그래야 루프가 그 하위 잡을 기다리고 리뷰어에게 보여 줍니다(Issue #34).
|
||||
- **결과 안내 및 피드백**:
|
||||
- *수동/영구 리뷰*: 상세 리뷰 결과, 설계 제안서 등은 `.mam/reports/<tmux_session_name>/report-<job_id>.md` 경로에 저장합니다.
|
||||
- *자동화 잡 보고서*: 자동 위임된 비동기 작업의 완료 결과는 잡 디렉터리 하위인 `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (루프/Discuss 위임 시에는 `<clean_session_name>-reports/`) 경로에 기록해야 합니다.
|
||||
|
||||
@@ -151,6 +151,7 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
|
||||
- **Task Delegation**:
|
||||
- *Manual path*: Detailed task briefs may be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
|
||||
- *Automated path*: The automated job runner (`multi-agent-mux-delegate-job submit`) automatically provisions the brief at `.mam/jobs/<job_id>/brief.md` and sends a short pointer instruction to the agent.
|
||||
- *Sub-delegation*: an agent that delegates part of its job passes `--parent-job <its job_id>` to `submit`, so the loop waits for the sub-job and shows it to reviewers (Issue #34).
|
||||
- **Result Reporting & Feedback**:
|
||||
- *Manual/Durable reviews*: Detailed reviews, design proposals, or audit reports must be saved under `.mam/reports/<tmux_session_name>/report-<job_id>.md`.
|
||||
- *Automated job reports*: Automated execution results are saved directly to `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (or `<clean_session_name>-reports/` for loops) as transient files.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 🛠️ Creator 수정 리포트 — codex R1(F2 실패 캐시) 반영 (job `5b2974f5`)
|
||||
|
||||
- **Author**: `creator-claude-01` (role: creator) · 2026-10-02
|
||||
- **작업 트리**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups` · `fix/followups-41-43` · `95eabc6` → **`75f364e`** (커밋 1개 추가, 미커밋 변경 없음, ignored 는 `.venv/` 뿐)
|
||||
- **범위**: R1 수정 + 경미한 문서 권고 1건. push·merge·PR·버전 bump 없음. 실제 `.mam`(이 리포트 제외)·`.mam.env`(export 안 함)·세션·herdr·모니터 무조작.
|
||||
|
||||
## 1. 판단
|
||||
|
||||
R1 은 타당하다 — 반론 없음. `ensure_env_loaded()` 가 `_load_dotenv()` 전에 `_env_loaded = True` 를 세웠고, F2 의 `load_env_or_report()` 가 예외를 반환값으로 바꾸면서 같은 프로세스의 두 번째 호출이 깨진 파일을 건너뛰게 됐다. 리뷰어가 권한 방향(성공 뒤에만 캐시)을 그대로 택했다.
|
||||
|
||||
## 2. 변경 (`75f364e`)
|
||||
|
||||
- `.agents/skills/lib_py/delegation/mqtt_common.py` `ensure_env_loaded`: `_load_dotenv(workspace_dir)` **뒤에** `_env_loaded = True`. 실패는 다음 호출에서 다시 읽고, 성공은 전처럼 프로세스당 한 번. `envfile.apply` 는 파일 전체를 파싱한 뒤에 대입하므로 실패한 로드는 환경을 하나도 바꾸지 않는다(부분 적용 없음) — 재시도가 안전하다. 라이브러리 직접 호출(`ensure_env_loaded`)도 같은 규칙이 된다.
|
||||
- `MESSAGING.md:369`(경미 권고): ERROR **한 줄**은 Python 진입점의 것이고, 래퍼는 그 줄 뒤에 `ERROR: cannot load <path> (see the line above)` 를 한 줄 더 찍는다고 정정.
|
||||
- `tests/test_issue26_envfile.py::test_a1_a_failed_load_is_not_cached_as_loaded` — 한 프로세스에서 실제 `registry.main(['--registry-dir', <jobs>, 'register', '--prompt', …])` 를 깨진 UTF-8 파일로 두 번 호출하고 파일을 복구·변경하며 확인:
|
||||
|
||||
| 리뷰어 요구 | 단언 |
|
||||
|---|---|
|
||||
| (a) 연속 실패 두 번 모두 rc 1 | `rc == [1, 1]`, ERROR 줄이 두 번 |
|
||||
| (d) 실패 중 등록 없음 | `registered == []` |
|
||||
| (b) 복구 후 설정 적용 | `load_env_or_report()` → `[True, "from-file"]` |
|
||||
| (c) 성공 후 once-per-process 유지 | 파일을 바꿔도 `[True, "from-file"]` |
|
||||
|
||||
## 3. 역검증
|
||||
|
||||
`git worktree add --detach <scratch>/head 95eabc6` 에 새 테스트 파일만 복사해 실행 → **실패**: `{'rc': [1, 0], 'registered': ['421e9769.json'], 'recovered': [True, None], 'cached': [True, None]}` (`assert [1, 0] == [1, 1]`). 리뷰어 탐침과 같은 결과다. 임시 워크트리는 제거했다(남은 워크트리: 본 체크아웃, followups, 그리고 내가 만들지 않은 `multi-agent-mux-wt/fu-suite` — 손대지 않음).
|
||||
|
||||
## 4. 테스트 (수정 후, 브리프 명령 형식, `.mam.env` export 없음)
|
||||
|
||||
`tests/test_issue26_envfile.py tests/test_issue5_phase1_delegation.py tests/test_tier1_unit.py tests/test_version_consistency.py tests/test_issue18_liveness_contract.py` → **205 passed in 178.52s** (envfile 38 · issue5 66 · tier1 67 · version 3 · issue18 31). issue5 의 `ensure_env_loaded` 멱등 단언(`:506-508`)도 통과. 바뀐 것은 env 로딩 캐시 한 곳뿐이라 나머지(F1·F3~F6) 파일은 다시 돌리지 않았다 — 직전 실행(`1e92ddc`/`95eabc6`) 결과와 리뷰어의 303 passed·52 passed 가 유효하다.
|
||||
|
||||
## 5. 위생
|
||||
|
||||
- `test_version_consistency` 가 기존부터 작업 트리에 `.mam/shim/herdr` 를 쓴다(직전 리포트에 보고한 base 동작). 내용이 그 심 하나뿐임을 확인하고 지웠다.
|
||||
- scratchpad 경로를 가진 남은 프로세스 0개.
|
||||
@@ -0,0 +1,140 @@
|
||||
# 🛠️ Creator 리포트 — PR #41·#43 후속 F1~F6 (job `aa7f2b5f`)
|
||||
|
||||
- **Author**: `creator-claude-01` (role: creator) · 2026-10-02
|
||||
- **작업 트리**: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups` · 브랜치 `fix/followups-41-43` · base `0051fd6` → head **`95eabc6`** (커밋 8개, 미커밋 변경 없음, ignored 는 `.venv/` 뿐)
|
||||
- **범위 준수**: 본 체크아웃(`main @ 0051fd6`)은 읽기만 했다. push·merge·PR·이슈 변경 없음, 버전 bump 없음(4.8.1 그대로). #17 항목 4·#39·#40·#42 범위는 건드리지 않았다. 실제 `.mam`(이 리포트 파일 제외)·`.mam.env`(읽지도 출력하지도 않음)·모니터·에이전트 세션·herdr 서버는 손대지 않았고 `.mam.env` 는 export 하지 않았다(테스트는 conftest 의 로컬 브로커). basetemp·JUnit·로그·역검증용 임시 워크트리는 모두 작업 트리 밖 scratchpad 에 두었고 임시 워크트리는 제거했다.
|
||||
|
||||
## 1. 커밋
|
||||
|
||||
| 커밋 | 항목 | 요지 |
|
||||
|---|---|---|
|
||||
| `75936f1` | F1 | A8 이 마커 lstart 를 `loop_lock.sh` `mam_lstart` 와 같은 정규화(공백 압축·양끝 trim)로 기록. 두 칸 공백 `ps` 심 변형으로 날짜와 무관하게 고정. 제품 코드 무변경 |
|
||||
| `5edf913` | F2 | `mqtt_common.load_env_or_report()` — publish_event·registry·job_subscriber·cli·bench·heartbeat·remote 의 main 이 `ERROR: <경로>: cannot read (<예외 종류>)` 한 줄 + rc 1. `ensure_env_loaded()` 는 여전히 예외(라이브러리는 exit 하지 않음) |
|
||||
| `95eabc6` | F2 보정 | job_subscriber 는 종료 코드를 이름으로 돌려준다(`test_issue5` 스냅숏): `return RC_ERROR`(값 1 그대로) |
|
||||
| `71ea809` | F3 | (a) 공통 파서를 `utf-8-sig` 로(BOM 제거, 두 로더 공통) (b) 중복 키 첫 값 우선을 `.mam.env.example` FORMAT·`envfile.py` 독스트링에 명시(동작 그대로) (c) `MESSAGING.md:369` 를 실제 동작(없으면 조용히 건너뜀·주변 탐색 안 함, 있는데 못 읽으면 `EnvFileError` → ERROR 한 줄 rc 1)으로 |
|
||||
| `8b04639` | F4a·F4c | 러너 `_int_knob` = `_positive_int` 규칙 + 잘못된 값 경고 1회 후 기본값. loop SKILL.md 예산 행·하위 잡 문단에 `MAM_WAIT_GRACE_S` 와 정수 규칙 |
|
||||
| `fb1b93c` | F4b | bash 노브 8개를 `mam_int_knob` 으로. `mam_int`/`mam_int_knob` 를 `lib_int.sh` 로 **그대로 이동**(lib.sh 가 source, lib.sh 없이 도는 `loop_lock.sh`·`diff_collect.sh` 는 함수가 없을 때 직접 source) |
|
||||
| `c252ac4` | F5 | `stall_watch.py` 의 `MAM_TEST_UNRELIABLE_AGENT` 분기 삭제, S13 은 어댑터 seam 으로 |
|
||||
| `1e92ddc` | F6 | delegate-job SKILL.md `<= 60 s` 를 herdr wait 에 한정, "대기 중 래퍼 소멸" 테스트를 두 번째 wait 안에서 소멸하도록 |
|
||||
|
||||
## 2. 항목별 내용과 완료 조건
|
||||
|
||||
### F1 — 날짜 의존 A8
|
||||
- 원인: `ps -o lstart=` 는 한 자리 날짜를 `Oct 2` 로 두 칸 패딩한다. 제품 `mam_lstart` 는 `tr -s ' '` 로 줄여 비교하는데 A8 은 `.strip()` 만 한 원값을 기록 → 살아 있는 holder 가 stale 로 보여 두 번째 루프가 거부되지 않음(매달 1~9일).
|
||||
- 수정(`tests/test_issue21_acceptance.py`): `_marker_lstart()` 가 `" ".join(out.split())`(= `tr -s ' '` + trim)로 기록. `@pytest.mark.parametrize("ps", ["real", "padded"])` — `padded` 는 PATH 앞의 `ps` 심이 lstart 요청에 항상 `Thu Oct 1 08:02:03 2026` 을 돌려주므로 **날짜와 무관하게** 두 칸 공백 경로를 지난다(심이 실제로 쓰였는지 `Thu Oct 1 …` 로 확인).
|
||||
- 같은 패턴 조사: 살아 있는 lstart 를 마커에 쓰는 테스트는 `test_o3_scoped_guard._get_lstart`(이미 `" ".join(split())`)뿐. `test_o2_race_free_lock` 의 마커는 고정된 stale 값이고, 그 파일의 `get_lstart()` 는 **정의만 있고 쓰이지 않는다**(죽은 코드 — 손대지 않음). 제품 쪽 다른 소비자: `loop_delegation_guard.sh` 는 양쪽을 split-join, `lib.sh` 의 `ps -o pid=,lstart=` 는 `strptime` 이라 두 칸에 무관.
|
||||
|
||||
### F2 — Python 진입점의 EnvFileError
|
||||
- `mqtt_common.load_env_or_report() -> bool`: `ensure_env_loaded()` 를 부르고 `EnvFileError` 면 `print(f"ERROR: {e}", file=sys.stderr)` 후 False. 각 main 은 `if not …: return 1`(job_subscriber 는 `RC_ERROR`). 메시지는 `envfile` 의 `"{path}: cannot read ({type})"` 그대로라 값·내용이 들어가지 않는다.
|
||||
- 범위: 브리프가 든 7개 main. **reconcile.sh 의 임베디드 구독자**도 `ensure_env_loaded()` 를 부르지만 main 이 아니라 목록 밖이라 그대로 두었다(트레이스백 유지) — 필요하면 후속.
|
||||
- 리뷰어 참고: job_subscriber 의 rc 1 은 원래 "감시한 잡이 error 로 끝남"(`RC_ERROR`)과 같은 값이다. 수정 전에도 트레이스백으로 rc 1 이었으므로 값은 바뀌지 않았고, 래퍼는 같은 파일을 먼저 읽고 스스로 rc 1 로 끝나 이 구독자를 띄우지 않는다. 별도 코드(예: 3 fatal)로 바꾸는 것은 계약 변경이라 하지 않았다.
|
||||
|
||||
### F3 — .mam.env 세부
|
||||
- (a) `envfile.parse` 가 `encoding="utf-8-sig"` — 파일 맨 앞 BOM 만 벗기며, 래퍼(`envfile.py --shell`)와 `mqtt_common` 이 같은 함수를 쓰므로 두 로더 공통.
|
||||
- (b) `apply()` 가 이미 있는 키를 건너뛰므로 첫 값이 이긴다(동작 무변경) — FORMAT 절에 `a key given twice keeps its FIRST value` 와 BOM 규칙 추가.
|
||||
- (c) MESSAGING 2번 항목을 실제로: 명시 파일이 없으면 조용히 건너뛰고 주변 탐색을 하지 않는다(환경변수와 기본값으로 동작), 있는데 못 읽으면 `EnvFileError` → ERROR 한 줄 rc 1. 기존 문장의 "error is logged"·"fail-closed (`RuntimeError`)" 는 코드에 근거가 없어 지웠다.
|
||||
- 참고(범위 밖, 손대지 않음): 같은 블록 3번 항목의 탐색 순서(`MAM_REAL_ROOT`·`WORKSPACE_ROOT`·cwd)는 `mqtt_common._load_dotenv`(모듈 위치에서 위로 `.agents`/`.git` 탐색)와 다르다.
|
||||
|
||||
### F4 — 정수 노브
|
||||
- (a) `runner._int_knob(name, minimum)` → None(미설정·잘못된 값, 후자는 노브당 경고 1회) 이면 호출부 기본값. 최솟값과 근거(`tests/test_issue26_ints.py` `RUNNER_INT_KNOBS`):
|
||||
|
||||
| 노브 | 최솟값 | 기본 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `MAM_WAIT_GRACE_S` | 0 | 300 | 0 = 예산만큼만 대기 |
|
||||
| `MAM_CHILD_WAIT_S` | 0 | 예산 + grace | 0 = 하위 잡을 한 번만 확인(test_issue34 가 0 사용) |
|
||||
| `MAM_JOB_TREE_MAX` | 1 | 50 | 0 은 모든 트리를 "over" 로 만든다; 기다리지 않기는 `MAM_CHILD_WAIT_S=0` |
|
||||
|
||||
동작 변화: `MAM_CHILD_WAIT_S` 는 전에 `float()` 이라 `0.5`·`1e3` 을 받았고 `abc` 는 ValueError, `MAM_JOB_TREE_MAX` 는 `abc`·빈 값이 ValueError, `MAM_WAIT_GRACE_S` 는 `" 30 "` 도 조용히 300 이었다 → 이제 모두 10진 정수 규칙(경고 후 기본값).
|
||||
- (b) bash 노브(`INT_KNOBS` 에 추가, `NOT_ARITHMETIC` 에서 제거):
|
||||
|
||||
| 노브 | 위치 | 최솟값 | 기본 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| `MAM_READY_EMPTY_GIVEUP` | lib.sh `wait_for_tui_ready` | 1 | 5 | 0 은 1 과 같게 동작 |
|
||||
| `SKS_EMPTY_GIVEUP` | lib.sh `_pane_quiescent` | 1 | 3 | 같음 |
|
||||
| `SKS_IDLE_RETRIES` | lib.sh `wait_for_agent_idle` | 0 | 2 | 0 = 재시도 없음 |
|
||||
| `SKS_IDLE_RETRY_BACKOFF_S` | 같음(sleep) | 0 | 10 | test_issue6 가 0 사용; 소수 사용처 없음 |
|
||||
| `MAM_LOCK_STEAL_RETRIES` | loop_lock.sh | 0 | 3 | 0 = 한 번 시도 |
|
||||
| `MAM_DIFF_MAX_BYTES`/`_LINES` | diff_collect.sh | 1 | 200000/4000 | 0 은 모든 diff 를 자름 |
|
||||
| `RECONCILE_POLL_INTERVAL` | reconcile.sh(sleep) | 1 | 15 | 0 은 바쁜 루프; 소수 사용처 없음 |
|
||||
|
||||
`SKS_QUIESCENT_INTERVAL` 은 **기본값이 0.5 초**라 소수가 실제 의미 → 그대로 두고 `NOT_ARITHMETIC` 에 "sleep, fractional by default (0.5) - left raw" 로 사유를 남겼다.
|
||||
- `lib_int.sh` 를 만든 이유: `diff_collect.sh` 는 Python 러너가 lib.sh 없이 `source` 하고(`runner.collect_changes_diff`), `loop_lock.sh` 도 테스트가 단독 source 한다. lib.sh 를 source 하면 herdr 심 설치 등 부작용이 있어 함수만 분리했다. 함수 본문은 한 글자도 바꾸지 않았다. 연쇄 변경: `deploy/remove.sh` 의 매니페스트 없는 폴백 목록에 `lib_int.sh` 추가(설치는 `.agents/skills` 의 모든 파일을 복사하므로 그대로 배포됨), `test_issue21_loop_b::l15` 가 가짜 제어 루트에 `diff_collect.sh` 만 두던 것을 `lib_int.sh` 도 함께 두게.
|
||||
- (c) loop SKILL.md: 예산 행에 `MAM_WAIT_GRACE_S`(기본 300, 0 이상, 잘못된 값 경고 후 300, 덮는 꼬리 설명), 하위 잡 문단의 `MAM_CHILD_WAIT_S` 기본을 "예산 + `MAM_WAIT_GRACE_S`", 0 의 의미, `MAM_JOB_TREE_MAX` 1 이상, 정수 규칙.
|
||||
|
||||
### F5 — 제품 코드의 테스트 훅
|
||||
- `StallWatch.__init__` 의 `MAM_TEST_UNRELIABLE_AGENT` 두 줄 삭제(이제 `.agents/skills` 전체에 `MAM_TEST_` 없음).
|
||||
- S13: `_watch(..., unreliable=True)` 가 `python -c _UNRELIABLE_MAIN claude <args>` 로 실제 `stall_watch.main()` 을 실행하되, 그 프로세스에서 `type(get_adapter("claude")).agent_status_reliable = property(lambda self: False)`. 검증 내용은 그대로(5 초 동안 idle 알림·nudge 없음 → blocked 알림 `stalled:blocked Do you want to proceed?` → 로그 `idle judgement off for claude`).
|
||||
|
||||
### F6 — 문서 정밀도와 "대기 중 래퍼 소멸"
|
||||
- SKILL.md: "한 herdr wait(<= 60 s) — 또는 알림 발행 중에 래퍼가 사라지면 그 발행이 끝날 때까지" 로 한정.
|
||||
- 테스트: `_during_notice` 에 `in_wait(tick)` 훅(각 가짜 herdr wait 안에서 호출). 래퍼는 **두 번째 wait 안에서** 사라지고, 테스트는 `ticks == [1, 2]`(두 번째 wait 에 실제로 들어감)와 그때까지 alive 가 참이었음을 확인한 뒤 알림·키 0 을 단언한다.
|
||||
|
||||
## 3. 회귀 테스트가 수정 전 코드에서 실패함을 확인한 방법
|
||||
|
||||
`git worktree add --detach <scratch>/base 0051fd6`(작업 트리 밖)에 새 테스트 파일을 복사해 돌리고, 확인 후 `git checkout` 으로 되돌렸다. 마지막에 `git worktree remove --force` 로 제거(남은 워크트리 2개: 본 체크아웃·followups).
|
||||
|
||||
| 항목 | base(0051fd6)에서 | 수정 후 |
|
||||
|---|---|---|
|
||||
| F1 | 원래 A8: **실패**(오늘 10월 2일, `already running` 없음). 새 테스트에서 `_marker_lstart` 를 옛 `.strip()` 으로 되돌리면 `real`·`padded` **둘 다 실패** — `padded` 는 날짜와 무관 | 2 passed |
|
||||
| F2 | 14 케이스(7 진입점 × 000 권한·UTF-8 깨짐) **14 실패**(트레이스백) | 14 passed |
|
||||
| F3 | A7 3개 **3 실패**: BOM 이 `FIRST` 를 버림 / FORMAT 문구 없음 / MESSAGING 의 옛 문장 | 3 passed |
|
||||
| F4a | 새 케이스 중 **15 실패**: grace 의 잘못된 값 6(경고 없이 300, `" 30 "` 은 300), child-wait `abc`·`-5`·`0.5`·`9999999999` 4(ValueError 또는 받아들임), tree-max `0`·`abc`·`-1`·빈 값 4(ValueError 또는 0 수용), 정적 검사 1. 통과한 14 는 결과가 같은 유효 값 | 44 passed |
|
||||
| F4b | **14 실패**: 표 정적 검사 2, lib.sh 없이 source 한 스크립트 11(`abc`·`0900`·`" 7 "`·`-1`·`1.5`, diff 는 `0` 도), `_pane_quiescent` + `SKS_EMPTY_GIVEUP=abc`(`[` 오류) 1. 통과 3 은 유효 값 | 통과 |
|
||||
| F5 | 정적 검사(`MAM_TEST_` 없음) **실패**. 추가로 수정 후 트리에서 seam 을 빼면(`unreliable=True` 제거) S13 이 **실패** → seam 이 unreliable 상황을 만든다는 증거 | 2 passed |
|
||||
| F6 | 테스트만 바뀐 항목이라 두 가지로 확인: (1) 새 테스트에 옛 호출 횟수 대역을 넣으면 `assert [1] == [1, 2]` 로 **실패** — 옛 테스트는 두 번째 wait 에 들어가지도 않고 루프 머리에서 끝났다. (2) 제품의 wait 직후 `_wrapper_gone()` 검사를 지운 변이체에서 새 테스트가 `notes == ['stalled:idle']` 로 **실패**(수정 안 한 제품에서는 통과) | 1 passed |
|
||||
|
||||
## 4. 테스트 결과
|
||||
|
||||
명령(브리프 그대로, `.mam.env` export 없음):
|
||||
`AGENT_PYTHON_BIN=<wt>/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 <wt>/.venv/bin/python -m pytest -p no:cacheprovider -q --basetemp=<scratch>/bt-final --junitxml=<scratch>/final.xml <파일들>`
|
||||
|
||||
최종 실행 @ `1e92ddc` — **605 passed, 1 failed, 2 skipped (885 s)**. 실패 1건은 내 F2 회귀였다(`test_issue5_phase1_delegation::test_job_subscriber_cli_contract_snapshot` — job_subscriber 의 맨 숫자 `return 1`). `95eabc6` 로 고친 뒤 `test_issue5_phase1_delegation.py` + `test_issue26_envfile.py` 재실행 **103 passed**. `95eabc6` 의 변경은 같은 값(1)을 이름(`RC_ERROR`)으로 바꾼 것뿐이고 그 분기는 env 파일 실패에서만 탄다.
|
||||
|
||||
| 파일 | 통과 | 실패 | 스킵 |
|
||||
|---|---|---|---|
|
||||
| test_issue26_envfile.py | 37 | 0 | 0 |
|
||||
| test_issue26_ints.py | 93 | 0 | 0 |
|
||||
| test_issue21_acceptance.py | 10 | 0 | 0 |
|
||||
| test_issue30_stall_watch.py | 48 | 0 | 1 (`/proc` 전용) |
|
||||
| test_issue30_stall_watch_stop.py | 11 | 0 | 0 |
|
||||
| test_tier1_unit.py | 67 | 0 | 0 |
|
||||
| test_version_consistency.py | 3 | 0 | 0 |
|
||||
| test_issue33_budget.py | 20 | 0 | 0 |
|
||||
| test_issue34_job_tree.py | 20 | 0 | 0 |
|
||||
| test_issue21_loop_b.py | 34 | 0 | 0 |
|
||||
| test_issue5_phase1_delegation.py | 65 → **재실행 66** | 1 → **0** | 0 |
|
||||
| test_issue18_liveness_contract.py | 31 | 0 | 0 |
|
||||
| test_o2_race_free_lock.py | 22 | 0 | 0 |
|
||||
| test_o3_scoped_guard.py | 32 | 0 | 0 |
|
||||
| test_b7_diff_untracked.py | 20 | 0 | 0 |
|
||||
| test_issue36_prompt_transport.py | 23 | 0 | 1 (Linux `MAX_ARG_STRLEN`) |
|
||||
| test_issue6_turn_handshake.py | 14 | 0 | 0 |
|
||||
| test_c1_tui_readiness.py | 23 | 0 | 0 |
|
||||
| test_b19_headless_reconcile_fixes.py | 16 | 0 | 0 |
|
||||
| test_issue19_subscriber_daemon_lifecycle.py | 16 | 0 | 0 |
|
||||
|
||||
브리프 지정 7개 파일 + 바꾼 모듈의 테스트(러너: issue33/34·loop_b, env/진입점: issue5·issue18, bash 노브: o2·o3·b7·issue36·issue6·c1·b19·issue19). 전체 스위트(~60분)는 돌리지 않았다(오케스트레이터 몫). 정적 검사: `bash -n` 으로 바꾼 셸 파일 6개 문법 OK; `shellcheck`·`pyflakes` 는 이 호스트에 없다.
|
||||
|
||||
## 5. 위생
|
||||
|
||||
- 작업 트리: 최종 `git status --short --ignored` = `!! .venv/` 만. 단, 최종 실행 중 **`.mam/shim/herdr` 가 작업 트리에 생겼다** → 파일별로 나눠 돌려 원인을 찾았다: `tests/test_version_consistency.py::_get_lib_sh_mam_version` 이 `WORKSPACE_ROOT` 없이 `lib.sh` 를 source → `_init_herdr_isolation` 이 `<저장소>/.mam/shim/herdr` 를 쓴다. **base 에서도 똑같이 생긴다**(내 변경과 무관, 기존 동작). 내용이 재생성되는 심 하나뿐임을 확인하고 지웠다. ⚠️ 본 체크아웃에서 이 테스트를 돌리면 **실제 `.mam/shim/herdr` 를 덮어쓴다** — 후속 후보(테스트가 `WORKSPACE_ROOT` 를 샌드박스로 주기).
|
||||
- 프로세스: 내 F5 확인(seam 을 일부러 뺀 S13 실패 실행)에서 **샌드박스 stall_watch 1개가 남았다**(S13 은 단언 실패 시 `finally` 에서 에이전트만 죽이고 watcher `w` 는 정리하지 않음 — 기존 테스트 구조). 그 watcher 는 basetemp 의 mock herdr·레지스트리만 상대했다. TERM 으로 종료했고, 이후 scratchpad 경로를 가진 프로세스 0개. 실제 모니터 데몬·세션은 건드리지 않았다(남은 `-m lib_py.loop` 1개는 오케스트레이터의 루프 드라이버).
|
||||
- 실제 `.mam/jobs` 의 레코드는 읽기·쓰기 하지 않았다(이 리포트 파일만 작성).
|
||||
|
||||
## 6. 리뷰어 참고
|
||||
|
||||
- `lib.sh` 변경은 `mam_int`/`mam_int_knob` 블록을 `lib_int.sh` 로 옮기고 그 자리에서 `source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"`. 생성되는 herdr 심(heredoc, 151–1367행)은 `mam_int` 를 쓰지 않는다.
|
||||
- `local x="$(mam_int_knob …)"` 형태는 B5 정적 검사 정규식(`mam_int_knob NAME default min)`)이 잡도록 기존 호출부 모양을 따랐다. B5 가 이제 래퍼와 **모든** `.sh` 를 읽는다.
|
||||
- 메모: 오래된 `.mam.env` export 지침(공개 브로커 시절)은 이 트리에서 틀리다 — conftest 가 세션 로컬 브로커를 띄우고 인증 변수를 지운다.
|
||||
|
||||
## 7. 교차 확인 명령
|
||||
|
||||
```
|
||||
cd /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/followups
|
||||
git log --oneline 0051fd6..HEAD # 8 commits, head 95eabc6
|
||||
git diff --stat 0051fd6..HEAD
|
||||
grep -rn MAM_TEST_ .agents/skills # nothing
|
||||
AGENT_PYTHON_BIN=$PWD/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \
|
||||
--basetemp=/tmp/<somewhere> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py \
|
||||
tests/test_issue30_stall_watch.py tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261025T023502Z-d6f876",
|
||||
"base_sha": "0461a632eb667c7988fd33be92e9a4b36151f22a",
|
||||
"head_sha": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"tree_sha": "15a72c9a1933c64018a2f2d9aa9707348905bb4d",
|
||||
"review_commit": "bb496fde785133ad1dbec288aa7431766efb6eb5",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "05af786c",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-25T03:40:44Z"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# PR #70 (Issue #66) 리뷰 — head bb496fd
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261025T023502Z-d6f876/1`
|
||||
- identity 확인: HEAD bb496fde785133ad1dbec288aa7431766efb6eb5, tree 15a72c9a1933c64018a2f2d9aa9707348905bb4d, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD 0461a63 그대로.
|
||||
- 범위: base 0461a63..bb496fd (커밋 1개). 바뀐 파일은 세 개다: `.agents/skills/lib_py/stall_watch.py`, `tests/conftest.py`, `tests/test_issue66_stall_watch_sanitized_name.py`.
|
||||
|
||||
## 1. 확인한 것과 근거
|
||||
|
||||
### 1) 완료 조건
|
||||
|
||||
herdr 를 부르는 곳은 모두 변환된 이름을 쓴다.
|
||||
- `HerdrSource.__init__` 이 `self.name = sanitize_herdr_agent_name(target)` 를 만든다 (`stall_watch.py:162`).
|
||||
- 이 이름을 쓰는 곳:
|
||||
- `agent get` (:183)
|
||||
- `agent read` (:206)
|
||||
- `agent prompt` (:211)
|
||||
- `watcher.wait_once(self.herdr_name, …)` (:331, 값은 :229)
|
||||
- `pane process-info` 와 `pane read` (:187, :193)는 `agent get` 이 돌려준 pane_id 를 쓰므로 이름과 상관이 없다.
|
||||
- `--herdr-session` 은 서버 이름으로만 쓰인다. `self.session` (:159) 과 `session=self.args.herdr_session` (:331) 이고, 변환되지 않는다. 둘을 섞어 쓴 곳은 없다.
|
||||
|
||||
로그와 알림은 원래 이름을 쓴다.
|
||||
- 로그 :373 (blocked), :397 (입력창 비어 있지 않음), :406 (idle) 은 모두 `self.args.target` 이다.
|
||||
- 알림 상세(`stalled:idle`, `stalled:blocked …`)에는 이름이 들어가지 않는다.
|
||||
- 회귀 테스트는 `agent {target} idle for` 와 `agent {target} is blocked` 가 원래 이름으로 나오는지 확인한다 (test :85, :106).
|
||||
|
||||
변환이 필요 없는 이름(`creator-claude-t`)은 그대로이고, 그 경우의 테스트도 통과한다 (test :68 의 PLAIN).
|
||||
|
||||
### 2) 회귀 테스트: base 에서 실패, head 에서 통과
|
||||
|
||||
- head: 7 passed.
|
||||
- base(0461a63) 사본에 head 의 새 테스트와 conftest 를 넣고 돌리면 5 failed, 2 passed 다.
|
||||
- 실패: LONG, UPPER 두 이름 각각의 idle nudge 와 blocked 보고 (4개), 긴 codex 이름의 종류 판정 (1개).
|
||||
- 통과: strict mock 자체를 확인하는 테스트와, 변환이 필요 없는 PLAIN.
|
||||
|
||||
### 3) 변환 함수가 lib.sh 와 같은 결과를 내는가
|
||||
|
||||
lib.sh `_sanitize_herdr_agent_name` (:293-316) 과 `lib_py.agents.sanitize.sanitize_herdr_agent_name` 에 같은 이름 10개를 넣어 비교했다.
|
||||
- 비교한 이름: 그대로 둘 이름, 대문자, 32자 초과(루프 세션 이름 포함), 정확히 32자, 33자, 숫자로 시작, `.`·공백·`@`, 비 ASCII `Ü`, `_` 로 시작.
|
||||
- 결과: 10개 모두 같고, 두 번 변환해도 바뀌지 않는다.
|
||||
- 그래서 호출자가 이미 변환된 이름을 넘겨도 안전하다.
|
||||
- 호출자는 원래 세션 이름을 넘긴다: `multi-agent-mux-delegate-job:431`, `:615` → `start_stall_watch "$AGENT_SESSION"` 와 `"$current_session"`.
|
||||
|
||||
### 4) resolve_agent 를 원래 이름으로 하는 것이 맞는가
|
||||
|
||||
맞다.
|
||||
- 긴 이름은 변환되면 끝이 해시로 바뀐다. 예: `mam-wt-issue-66-very-long-creator-codex` → `…-<sha8>`. 그래서 종류는 원래 접미사에서 읽어야 한다.
|
||||
- `HerdrSource` (:163) 과 `StallWatch` (:230) 둘 다 원래 이름을 쓴다.
|
||||
- 테스트 :111-129 가 `--agent ""` 일 때 codex 로 정해져 nudge 가 가는 것을 확인한다.
|
||||
- 종류를 변환된 이름에서 읽도록 바꾼 변이(m3)는 이 테스트에 잡힌다.
|
||||
|
||||
### 5) conftest `strict_agent_names`
|
||||
|
||||
- 기본(키 없음)일 때 `_match_agent` 는 예전과 똑같이 네 가지 비교를 한다 (`conftest.py` 수정분 :473-476). get/read 의 실패 출력도 예전 문구 그대로다. 그래서 기존 테스트의 동작은 바뀌지 않는다.
|
||||
- 켜면 등록된 이름과 정확히 같을 때만 답하고, 아니면 `{"error":{"code":"agent_not_found"}}` 를 stderr 에 쓰고 exit 1 한다. 실제 herdr 처럼 등록 이름(변환된 이름)으로만 찾는다.
|
||||
- 테스트 :51-65 가 get, read, wait, prompt 네 명령 모두 원래 이름에는 agent_not_found 를, 변환된 이름에는 rc 0 을 내는지 확인한다.
|
||||
|
||||
### 6) macOS 호환
|
||||
|
||||
- `/usr/bin/python3` 3.9.6 으로 `lib_py.stall_watch` import 와 `resolve_agent` 호출이 된다.
|
||||
- 새 코드에 `X | Y`, `match`, tomllib 가 없다. 셸은 바뀌지 않았다.
|
||||
|
||||
### 7) test_issue17_item4_decisions::test_c_finalize_ends_with_an_unreaped_wrapper[broker_unavailable-3-0]
|
||||
|
||||
이 변경과 무관하다.
|
||||
- 이 테스트는 래퍼의 finalize 를 자체 shim(`PY=<shim>`)으로 돌린다. stall watcher 는 argv 를 기록하는 대역이다 (`tests/test_issue17_item4_decisions.py:218`).
|
||||
- conftest 의 mock herdr 와 `lib_py.stall_watch` 를 쓰지 않는다.
|
||||
- 판정 조건은 `took < 4.5` 와 terminate 뒤 레코드가 그대로인지 (:598-601) 로, 시간과 정리 단계에 의존한다.
|
||||
- 이 PR 이 바꾼 것(stall_watch 의 이름 변환, 기본 꺼짐인 mock 옵션)은 이 경로에 닿지 않는다.
|
||||
- 이 호스트에서도 통과했다(아래 표).
|
||||
|
||||
## 2. 실행한 테스트
|
||||
|
||||
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. .mam.env 를 export 하지 않았다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로 `tests/test_issue66_stall_watch_sanitized_name.py` | 7 passed |
|
||||
| base 사본 + 새 테스트·conftest | 5 failed, 2 passed |
|
||||
| 검증 경로: test_issue30_stall_watch, test_issue30_stall_watch_stop, test_issue17_item4_decisions, test_tier2_component, test_issue63_same_cwd_ids, test_issue50_dispatch_herdr, test_c1_tui_readiness (mock herdr 를 쓰는 인접 테스트) | 457 passed, 1 skipped, 0 failed |
|
||||
|
||||
변이 테스트 (head 사본, issue66 테스트):
|
||||
|
||||
| # | 변이 | 결과 |
|
||||
|---|---|---|
|
||||
| m1 | `wait_once` 를 원래 이름으로 | 잡힘 (5 failed) |
|
||||
| m2 | `agent prompt` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||
| m3 | 종류를 변환된 이름에서 판정 | 잡힘 (1 failed) |
|
||||
| m4 | blocked 로그를 변환된 이름으로 | 잡힘 (2 failed) |
|
||||
| m5 | `agent read` 를 원래 이름으로 | 잡힘 (3 failed) |
|
||||
|
||||
실제 herdr 서버(pid 19169 `herdr server`, 23492 `herdr --session multi-agent-mux server`)는 테스트 전후로 같다.
|
||||
|
||||
## 3. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 4. 비차단
|
||||
|
||||
- **N1**: 변환된 이름을 두 곳에서 따로 만든다(`HerdrSource.name` :162, `StallWatch.herdr_name` :229). 주입된 source 를 쓰는 테스트 때문에 둘 다 필요해 보인다. 결과가 같으므로 동작에는 문제가 없다.
|
||||
- **N2** (기존, 이 PR 과 무관): `--agent` 가 비어 있고 이름이 `…-claude-01` 처럼 숫자로 끝나면 `resolve_agent` 는 종류를 정하지 못한다(None). 실제 호출자는 항상 `--agent "$AGENT"` 를 넘기므로 지금은 영향이 없다.
|
||||
- **N3** (기존, 이 PR 과 무관): lib.sh 에 `_sanitize_herdr_agent_name` 이 두 벌 있다(:36, :293). sha 도구가 없을 때의 대처가 서로 다르다(:36 은 32자로 자르고, :293 은 python3 로 해시). 이 호스트처럼 shasum 이 있으면 두 벌 모두 Python 판과 같다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,128 @@
|
||||
# 🔍 Review Report — PR #38 병합 전 독립 리뷰 (job `107d435d`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-25
|
||||
- **대상**: PR #38 `fix/issue-34` **@ `ed293dc`**, base `main 7a4a0c2` (v4.7.3). 6커밋, 11파일 `+924/−16`, `git diff --check` 클린.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260925T055502Z-a37cf1/1`. **identity 확인**: `HEAD = ed293dce…67cf`, `HEAD^{tree} = 671b0e59…899c` — 브리프와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-38`)·본 체크아웃(`main 7a4a0c2`) 모두 수정·커밋·checkout 없음. 등록부 실험은 scratchpad 임시 등록부에서 `registry.py` 를 직접 호출(래퍼 미사용, 실제 herdr 에 잡 생성 없음), 러너 실험은 in-process(`env -u MAM_VERSION`, 임시 git 저장소), lib.sh 는 함수 추출 + `env -i`. 실제 `.mam`·데몬·세션·herdr 서버 조작 없음, push·merge·PR 상태 변경 없음. 이 pane 의 `HERDR_PANE_ID`/`HERDR_WORKSPACE_ID` 는 **읽기만** 했다.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**병합을 막을 결함은 없다.** 부모 기록·자손 대기·리뷰 근거 표·stale 가드·같은 서버 다른 워크스페이스 조회 규칙을 각각 독립 실험으로 확인했고, 새 테스트 17건 중 **16건이 수정 전 소스에서 실패**한다. 비차단 관찰 4건(K1–K4). 그중 K1(귀속되지 않은 열린 잡만 있을 때 판정 규칙 문구가 없음)은 브리프 항목 2·4 의 "충분한 근거" 질문에 직접 닿는다.
|
||||
|
||||
## 1. 부모 기록 (항목 1)
|
||||
|
||||
| 확인 | 결과 |
|
||||
|---|---|
|
||||
| `--parent-job` 검증 | `registry.py register`(`:540-544`) 가 등록부에 없는 부모를 `unknown parent job <id>` + rc 1 로 거부. 래퍼(`:217-226`)는 명시 `--parent-job` 을 그대로 넘겨 **같은 검증**에 걸리게 하고, 기본 `MAM_JOB_ID` 는 이 등록부에 그 레코드가 있을 때만 쓰며 없으면 `WARNING: … registering without a parent job` 후 부모 없이 등록 — 두 층의 규칙이 일치한다 |
|
||||
| 자기 참조 | `--job-id X --parent-job X` 는 등록 시점에 X 가 없어 거부됨(실측). `update` 에는 `--parent-job` 이 없어 **부모는 등록 후 불변** → 정상 경로로는 순환이 만들어질 수 없다 |
|
||||
| 순환 방어 | 레코드 파일을 직접 고쳐 `root.parent = grandchild` 순환을 만든 뒤 `descendants_of` 를 돌려도 종료한다(`seen` 집합, 방문 1회): root 기준 `[(child,1),(grandchild,2)]`, grandchild 기준 `[(root,1),(child,2)]` |
|
||||
| `depth` | 저장하지 않고 BFS 에서 계산(자식 1, 손자 2 — 실측) |
|
||||
| 비용 | `descendants_of` 는 폴링(3 s)마다 `list_jobs` **전체 스캔** 1회(레코드 수 N 의 JSON 읽기). `_subjobs_block` 은 회차마다 root 수만큼 스캔 + `unattributed_open_jobs` 1회. 수백 건이면 문제없고 수천 건이면 3 s 폴링당 수천 파일 읽기 — 등록부 정리 정책이 없는 오래된 제어 평면에서 체감될 수 있다(K4) |
|
||||
| `MAM_JOB_TREE_MAX`(50) | 넘으면 `log_error` 후 **기다리지 않고** 리뷰로 간다. 안전한 쪽인가: 열린 자손은 `[하위 위임 잡]` 표에 OPEN 으로 나오고 SUBJOB_RULE 이 NOT PASS 를 지시하므로 "조용히 통과"는 아니다. 다만 51개 이상의 행이 프롬프트에 붙는다 |
|
||||
|
||||
## 2. 부모 ID 전달 (항목 2)
|
||||
|
||||
- 로컬 에이전트: 래퍼가 주입문 끝(`_instructions`, test18 문구 보존)과 `brief.md` 의 `Parent for sub-delegations` 줄(`:251`, `:427`), create 의 온보딩 주입문(`create_session.sh:646`)으로 알린다. 에이전트가 붙이지 않으면 추적이 빠지는 것은 사실이고, 안전망은 두 가지다: (a) `[귀속되지 않은 열린 잡]` 줄 — `unattributed_open_jobs` 가 "부모 없음 + pending/running + 이 루프가 만든 잡 제외 + creator 잡 등록 이후" 로 고른다(실측: 루프 자신의 뒤이은 잡은 제외, 남의 root 잡은 표시); (b) `--wait-open-jobs` 로 그 잡들까지 대기(기본 off — 같은 제어 평면의 무관한 잡이 루프를 잡을 수 있으므로; 문서와 일치).
|
||||
- **K1 (비차단, 근거 충분성)**: 하위 잡이 하나도 없고 귀속되지 않은 열린 잡만 있을 때 블록은 `[하위 위임 잡] 없음 … [귀속되지 않은 열린 잡]: <id>(…)` 만 담고 **SUBJOB_RULE 을 붙이지 않는다**(`_subjobs_block` 의 `if not rows:` 분기 — 실측: `has rule: False`). SUBJOB_RULE 자체도 "하위 잡"만 말하고 귀속되지 않은 잡을 어떻게 볼지는 말하지 않는다. 즉 "creator 가 `--parent-job` 을 빠뜨린" 바로 그 경우에 리뷰어는 사실은 받지만 지시는 받지 못한다. 한 문장("귀속되지 않은 열린 잡이 이 작업의 하위 위임으로 보이면 OPEN 하위 잡과 같이 취급")을 stray 줄 뒤에 붙이면 닫힌다.
|
||||
- 원격 러너: `MAM_JOB_ID` 가 자동 부모가 되지만 원격 호스트의 등록부에는 그 잡이 없을 수 있어 "있을 때만" 규칙이 맞다(래퍼 주석과 일치).
|
||||
|
||||
## 3. 자손 대기 (항목 3)
|
||||
|
||||
- `wait_for_job_tree` 는 실행(`:1291`)·수정(`:1520`)·순응(`:1591`) 세 creator 잡 모두에 걸리고, `round_roots` 에 누적된다.
|
||||
- `MAM_CHILD_WAIT_S`(3900) 의 기준점은 `wait_for_descendants` 진입 시각 = **creator 잡 종결 시점**(문서와 일치). 초과 시 `log_warn` 후 False 를 돌려주지만 `wait_for_job_tree` 는 root 의 결과만 반환하므로 루프는 리뷰로 진행하고 열린 잡은 표에 OPEN 으로 남는다(설계대로).
|
||||
- `interrupted`: 시그널 핸들러가 체크포인트 저장 후 `sys.exit(128+signum)` 하므로(`_handle_signal`) 폴링 루프의 `if self.interrupted: return False` 는 실제로는 닿지 않는 방어 코드다 — 무해.
|
||||
- 분류(`_subjob_state`)와 `get_feedback` 의 실제 값: 래퍼는 마감 시 `close --request deadline_timeout` 뒤 `publish_event --event error --detail deadline_timeout`(inject 단계면 `agent_blocked`)을 남기고(`:928-930`), preflight/idle 실패는 `cancelled --detail session_busy` 또는 새 `target_not_found`(`:896`, `:952`), create 롤백은 `cancelled "rolled back by create_session"` 이다. `get_feedback` 은 마지막 `completed/error`(감사 로그) 또는 events.log 의 detail 을 돌려주므로 `ORCHESTRATOR_CLOSED` 튜플·`startswith("rolled back")`·`session_busy|target_not_found` 가 정확히 그 문자열들과 맞는다. 사유 없는 `error` 를 CLOSED-UNCONFIRMED 로 보는 규칙은 "에이전트 자신의 보고가 아니다" 는 뜻에서 보수적이며 옳다.
|
||||
|
||||
## 4. 리뷰어 근거 (항목 4)
|
||||
|
||||
in-process 실측(`_subjobs_block`):
|
||||
|
||||
| 상황 | 결과 |
|
||||
|---|---|
|
||||
| 하위 잡 없음·stray 없음 | **`''`** — 프롬프트 바이트 동일 |
|
||||
| 이 루프가 뒤에 만든 잡(`created_jobs`)만 존재 | `''` — 리뷰어 자신의 잡·앞 회차 리뷰 잡은 stray 로 오인되지 않는다(회차의 블록은 리뷰 잡 위임 **전에** 계산된다) |
|
||||
| 남의 root 잡(부모 없음, creator 잡 이후 등록) | 블록 생성, stray 줄에 id 표시, **규칙 문구 없음**(K1) |
|
||||
| pending 자식 1개 | 표(`\| OPEN \| - \| 1 \|`) + 집계 + SUBJOB_RULE |
|
||||
|
||||
`descendants_of` 가 `depth` 를 주고 `_subjob_state` 가 상태·사유를 주므로 표의 열은 정확하다. 표는 앞 회차 creator 잡의 자손까지 매 회차 다시 읽는다(`round_roots` 누적, T9).
|
||||
|
||||
## 5. stale 가드 — 옵션 없는 루프 (항목 5)
|
||||
|
||||
`_fingerprint`(`runner.py:954-972`)는 `head` 와, `.mam`(`mam_dir`)·`jobs_dir`·감사 로그 디렉터리(`DELEGATE_JOB_LOGS_DIR`, 상대면 `workspace_root` 기준으로 결합)를 `git add -A -- . :(exclude)<rel>` 로 뺀 tree 다. in-process 실측(임시 git 저장소, **`.gitignore` 없음** — 즉 `.mam` 이 무시되지 않는 최악 조건):
|
||||
|
||||
| 상황 | 결과 |
|
||||
|---|---|
|
||||
| `DELEGATE_JOB_LOGS_DIR=.mam/delegate_job_logs`(상대) 로 감사 로그·`jobs/*.json` 을 새로 쓴 뒤 | 지문 **불변** — 제어 평면 쓰기는 제외된다 |
|
||||
| 소스에 untracked 파일 1개 추가 | 지문 **변경** |
|
||||
| 감사 로그 디렉터리가 소스 **밖**(절대경로) | `relative_to` 가 `ValueError` → 제외 목록에서 빠지고 지문 계산은 정상 |
|
||||
|
||||
- **켜지는 조건**: `light = self._fingerprint()` 는 `not identity and subjobs`(`:1350-1352`) — 하위 잡 표나 stray 줄이 있을 때만이다(1d4f338). 하위 위임 없는 루프는 옛 동작 그대로라 `test_tier4_e2e.py::test_e2e_scenario5` 류(소스에 매 잡 바뀌는 untracked 가 있는 경우)가 다시 살아난다 — 이번 실행에서 tier4 포함 통과(§7).
|
||||
- **적용 지점**: 셀프 리뷰 뒤(`:1368`), 리뷰 판정 수집 뒤(`:1463`, identity 모드의 B7 과 `elif` 로 배타), 승격 직전(`:1595`, identity 모드의 B10 은 `:1613` 로 별도). identity 모드에서는 `light` 가 `None` 으로 남아 **이중 적용이 없다**.
|
||||
- 지문을 만들 수 없으면(예: dirty 서브모듈) 경고 1회 후 가드 off — 옵션 없는 모드의 기존 동작으로 되돌아가는 것이라 수용 가능하다.
|
||||
|
||||
## 6. 같은 서버 다른 워크스페이스 위임 (항목 6)
|
||||
|
||||
`_herdr_scope_is_pane_inherited`(`lib.sh:1464-1473`)와 `mam_herdr` 의 `elif`(`:1485-1486`)를 함수 추출 + `env -i` 로 10가지 조합에 돌렸다(전부 설계와 일치):
|
||||
|
||||
| 호출 | 환경 | 결과 |
|
||||
|---|---|---|
|
||||
| `agent`/`has-session`/`kill-session`(`-L` 접두 포함) | `HERDR_WORKSPACE_ID == HERDR_PANE_ID` 의 워크스페이스, 같은 서버 | **WS id 만 제거**(pane·tab id 유지) |
|
||||
| `new-session`, `pane split` | 동일 | **유지** — pane 배치·워크스페이스 패킹(#7) 불변 |
|
||||
| `agent` | WS id ≠ pane 의 워크스페이스(어긋난 스코프) | 유지(의도된 스코프) |
|
||||
| `agent` | pane id 없음(pane 밖 명시 스코프) | 유지 |
|
||||
| `agent` | 기본 서버 pane → 이름 있는 서버 | **CROSS-all3** — #23 규칙이 `if` 로 먼저 잡아 세 id 모두 제거, 이 규칙은 `elif` 라 닿지 않는다(충돌 없음) |
|
||||
| `agent` | 대상 미지정(앰비언트) | WS id 만 제거(같은 서버이므로 같은 취급) |
|
||||
|
||||
- 이 pane 의 실제 값도 규칙의 전제(`HERDR_PANE_ID = "<ws>:<pane>"`, `HERDR_WORKSPACE_ID = <ws>`)와 맞았다(값은 읽기만).
|
||||
- 이름 유일성 근거(herdr `--skill` 문구)는 코드 주석에 인용돼 있고, T10/T11 이 두 방향을 고정한다.
|
||||
- `target_not_found`: preflight 의 `has-session` 실패가 `PREFLIGHT_REASON` 을 세우고(`:952`) `finalize_unfinished` 가 `cancelled --detail target_not_found` 로 닫는다(`:896`). **rc 는 종전과 같은 5** 다(T12 가 `returncode == 5` 를 고정) — 즉 이벤트 detail 은 정확해졌지만 종료 코드는 "busy" 와 같아서, 루프의 busy 1회 재시도(`MAM_SESSION_BUSY_RETRY_S`)가 없는 대상에도 한 번 더 돌아간다(K3).
|
||||
|
||||
## 7. 새 테스트 (항목 7)
|
||||
|
||||
- **실제 경로**: T1·T12 는 실제 래퍼(`--agent human`, 도달 불가 브로커)와 `registry.py` 로 등록·종결한다. T3~T9 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 **실제 래퍼**(`FAKE_REAL_WRAPPER`)로 하위 잡을 등록해 자손 대기·표·가드를 통과시킨다. T10~T12 는 mock herdr 위의 샌드박스. `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 는 `tmp_path`.
|
||||
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 6파일을 `7a4a0c2` 판으로 되돌리고 새 파일을 돌린 결과 **16 failed / 1 passed**(16.7 s). 통과 1건은 T11("호출자가 고른 워크스페이스 id 는 여전히 스코프") — 정정 전에도 성립해야 하는 불변 조건이다.
|
||||
- **검증 경로에서의 실행** (9파일: 새 파일 + issue5 phase1·tier4 e2e·issue21 loop B/acceptance·issue23/24·loop_cli·issue25·herdr shim contract):
|
||||
|
||||
```
|
||||
→ 1 failed, 243 passed in 1460.19s (0:24:20)
|
||||
FAILED tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts
|
||||
```
|
||||
|
||||
- 새 파일 17건 **전부 통과**, `test_tier4_e2e.py::test_e2e_scenario5`(브리프 항목 5 의 회귀) **통과**(873 s — 이 호스트에서 다른 스위트가 동시에 돌아 load avg 2.5 였다), registry CLI 스냅샷·help 캡처(issue5 phase1) 통과 → `--parent-job` 추가가 기존 CLI 계약을 깨지 않는다.
|
||||
- **실패 1건은 부하 flake** 로 판정한다: s7c 는 create/registry 의 `started` 증거 시험이며 PR 은 그 경로를 건드리지 않았다(create 는 주입문에 문장 하나 추가뿐). 합동 실행에서 40 s 걸려 `_seed_job` 단계(`--ack-timeout 2`)에서 실패했고, **단독 재실행에서는 s7b 와 함께 2 passed in 10.8 s** 로 통과했다.
|
||||
- **격리**: 실제 `.mam/jobs`·`.mam/delegate_job_logs` 전후 **차이 0 / 차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `ed293dc`), 본 체크아웃 `main 7a4a0c2`.
|
||||
- **실행 시간**: 새 파일 자체는 되돌린 사본 기준 17 s 안팎이고, 합동 실행의 24 분은 tier4 e2e(약 17 분)와 호스트 부하 몫이다.
|
||||
|
||||
## 8. 문서 (항목 8)
|
||||
|
||||
- loop SKILL.md `:214`(`--wait-open-jobs` 표), `:221`(하위 위임 잡: 대기 범위·`MAM_CHILD_WAIT_S` 기준점·`MAM_JOB_TREE_MAX`·표/규칙·회차 누적), `:223`(stale 가드) — 코드와 일치한다.
|
||||
- delegate-job SKILL.md `:49-53`·Best Practices, MULTI_AGENT_RULES(ko/en) `:154` — `--parent-job` 의무와 기본값·거부 규칙이 서술돼 있다.
|
||||
- 래퍼 usage 와 `brief.md` 의 `Parent for sub-delegations` 줄, create 온보딩 주입문 — 모두 같은 문장이다.
|
||||
|
||||
## 9. 병합 차단 여부 (항목 9)
|
||||
|
||||
버전 락스텝 bump 부재(`lib.sh:32` `4.7.3`, 오케스트레이터가 4.7.4 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||
|
||||
| # | 비차단 관찰 | 근거 / 권고 |
|
||||
|---|---|---|
|
||||
| K1 | 귀속되지 않은 열린 잡**만** 있을 때 블록에 SUBJOB_RULE 이 없고, 규칙 문구도 그 잡을 언급하지 않는다 — `--parent-job` 을 빠뜨린 바로 그 경우에 리뷰어가 지시 없이 사실만 받는다 | `runner.py:697-724` 의 `if not rows:` 분기, 실측 `has rule: False`. stray 줄 뒤에 한 문장 권고 |
|
||||
| K2 | `MAM_JOB_TREE_MAX` 초과 시 대기를 건너뛰고 리뷰로 간다 — 표의 OPEN 행과 규칙이 NOT PASS 를 유도하므로 조용한 통과는 아니지만, 50+ 행이 프롬프트에 붙는다 | `:646-654`. 정보 |
|
||||
| K3 | `target_not_found` 는 이벤트 detail 만 바뀌고 래퍼 rc 는 `session_busy` 와 같은 **5** 라 루프의 busy 1회 재시도가 없는 대상에도 돈다 | `:896`, `:952`, T12 `returncode == 5`. rc 분리 권고(후속) |
|
||||
| K4 | `descendants_of`/`unattributed_open_jobs` 는 폴링(3 s)마다 등록부 전체 JSON 을 읽는다 — 수천 건 누적된 제어 평면에서 체감 가능 | `registry.py:259-282`. 등록부 정리 정책 또는 인덱스가 생기기 전까지는 정보 |
|
||||
|
||||
## 10. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T055502Z-a37cf1/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # ed293dc… / 671b0e5…
|
||||
# 등록부: 부모 기록·list --parent·미지 부모/자기 참조 거부 (임시 등록부, registry.py 직접)
|
||||
# descendants_of: 손자 depth 2, 레코드 파일로 순환을 만들어도 종료
|
||||
# 러너 in-process (env -u MAM_VERSION): _subjobs_block '' / stray / child 표, _fingerprint 의 .mam 제외
|
||||
# lib.sh: _herdr_scope_is_pane_inherited 10케이스 (§6)
|
||||
# 되돌린 사본: 소스 6파일을 7a4a0c2 판으로 → tests/test_issue34_job_tree.py → 16 failed / 1 passed
|
||||
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt \
|
||||
tests/test_issue23_24_startup_path.py::test_s7c_a_started_that_was_followed_by_a_terminal_status_still_counts) # 단독 통과
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261008T133858Z-9e7f5b",
|
||||
"base_sha": "6b00b6e06f55299cb60f156ccef09ede4bae5bdd",
|
||||
"head_sha": "ac48cb78617af88584db529b5766acf02f4af73c",
|
||||
"tree_sha": "b043034eda891bd83b547dcb34d7422a6977b89d",
|
||||
"review_commit": "ac48cb78617af88584db529b5766acf02f4af73c",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "1364df46",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T18:40:20Z"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 리뷰 리포트 — Job 1364df46 (Issue #17 후속 3회차: 상속 WORKSPACE_ROOT, 6b00b6e..ac48cb7)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head ac48cb7 / tree b043034 — 검증 경로(…/3)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 2회차 head 8b47cc7 위에 커밋 1개(ac48cb7, +9줄): test_o2_race_free_lock.py 의 o2_12 에 테스트 단위 `own_workspace_root` 마크, test_o1_rebuttal.py 에 모듈 마크, W1 NODES 에 두 노드 추가. 제품 코드·버전·VERSIONS 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
2회차의 차단 지적(test_o2_race_free_lock.py:196-203 이 run_loop.sh → lib.sh 로 자기 체크아웃의 `.mam/shim/herdr` 를 씀)이 닫혔고, grok 이 지적한 test_o1_rebuttal 도 같은 방식으로 덮였다. 2회차에서 깨끗했던 나머지 후보들은 그대로 깨끗하다. 차단 결함 없음.
|
||||
|
||||
## 1. 지적 종결
|
||||
| 지적 | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| claude(2회차): o2_12 가 `dict(os.environ)` 로 run_loop.sh 실행, 마크·sandbox 없음 → 체크아웃 shim 재작성 | test_o2_race_free_lock.py:196-198 — o2_12 에만 `@pytest.mark.usefixtures("own_workspace_root")`(모듈 마크를 피한 이유: o2_7 이 tmp_path 항목 수를 센다 — 타당); W1 NODES 에 o2_12 추가(:41) | HEAD 사본에서 `WORKSPACE_ROOT=<사본>` 으로 o2_12 단독·파일 전체(22 passed) 모두 `.mam` **미생성**; `WORKSPACE_ROOT` 미설정으로도 미생성(2회차에는 두 경우 모두 생성) |
|
||||
| grok: test_o1_rebuttal 도 run_loop.sh 실행 | 모듈 마크(:10-12), W1 NODES 에 v3 노드 추가(:42) | HEAD 사본 파일 전체(10 passed) 미생성. 새 W1 노드가 의미 있는지: base 트리에서 그 노드 1개를 `WORKSPACE_ROOT=<base 사본>` 으로 실행 → `.mam/shim/herdr` **생성** — 수정 전 결함을 실제로 고정한다 |
|
||||
|
||||
## 2. 놓친 모듈이 더 없는가 (2회차 방법 반복)
|
||||
HEAD 트리 사본에서 `WORKSPACE_ROOT=<사본>` 으로 내 후보 13개 파일(test_o2, test_o1, test_issue30_stall_watch_stop, test_issue36_prompt_transport, test_issue19, test_a4, test_issue21_acceptance, test_issue20, test_issue34, test_issue23_24, test_b4, test_issue15, test_issue10_13; lib.sh 를 source 하는 코드를 실행하면서 sandbox 인자가 없는 테스트 50개)을 파일별로 돌리고 매번 `.mam` 을 확인: **13개 모두 clean**. 2회차에 유일하게 썼던 o2 가 이제 깨끗하다.
|
||||
|
||||
## 3. 할 일 4 — 실행 (검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| (a) WORKSPACE_ROOT 없이: 수정 16개 파일(W1·W2 포함) + test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **675 passed / 0 failed** (862 s) |
|
||||
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 수정 16개 파일 | **522 passed / 0 failed** (707 s); 실행 뒤 센티널 **비어 있음** |
|
||||
| 본 체크아웃 .mam/shim/herdr mtime | 전후 동일(1791480333); 실제 .mam/jobs 항목 수 불변 |
|
||||
- W1 은 (a)(b) 양쪽에서 통과했고, 이제 15개 노드(모듈 15개)를 체크아웃 사본에서 돌려 `.mam` 미생성을 고정한다.
|
||||
- 이식성: Python 테스트 코드만. bash 3.2·macOS 영향 없음.
|
||||
|
||||
## 4. 1·2회차 판단 유지
|
||||
`_env` 의 tmp_path 지정(lib.sh:26 기본값 때문), conftest `pytest_configure` 의 "다른 체크아웃만 제거" 정책과 opt-in fixture 설계(autouse 불가 이유 포함), unittest 클래스의 명시 env, test_orc_onboard 의 호출 시점 root — 모두 이전 리포트대로 타당하다. W1·W2 가 base 에서 실패함은 2회차에 확인했다.
|
||||
|
||||
## 5. 비차단
|
||||
- W1 의 모듈 목록은 수동 유지다(2회차 비차단 그대로). 새 테스트가 lib.sh 를 source 하는 모듈을 추가하면 목록도 늘려야 한다는 점을 conftest 의 fixture docstring 이나 W1 머리말에 한 줄 적어 두면 좋다.
|
||||
- 새 관찰 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260924T085626Z-b8987e",
|
||||
"base_sha": "59be1c89209a232059aec0c5aeecc2329929cb49",
|
||||
"head_sha": "708262a598c4301c0ccc70efba5401093b4b1e30",
|
||||
"tree_sha": "d601c2d01bb4c09931e5e1989e140b01d51f5b96",
|
||||
"review_commit": "708262a598c4301c0ccc70efba5401093b4b1e30",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "1a9fbbc4",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-24T09:09:43Z"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# 🔍 Review Report — PR #37 델타 리뷰 2회차 (job `1a9fbbc4`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||
- **대상 델타**: `59be1c8..708262a` (1커밋, 3파일 `+63/−7`, `git diff --check` 클린), base `main 1debc7d` (v4.7.2).
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T085626Z-b8987e/1`. **identity 확인**: `HEAD = 708262a5…1e30`, `HEAD^{tree} = d601c2d0…5b96` — 브리프와 일치.
|
||||
- **1회차 내 판정**: PASS + H1~H5 (job `4ab12ae2`). 이번에는 H1(러너 쓰기 실패)·H3(빈 파일)·S9 이식성의 정정을 본다.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 러너 실험은 in-process 스텁, 래퍼는 `--dry-run`+`--agent human`+도달 불가 브로커, 되돌린 사본은 scratchpad. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 내가 1회차에서 H1 을 재현했던 바로 그 스크립트를 정정된 러너에 다시 돌려 "실패 제출·부분 파일 없음·래퍼 미실행"을 확인했고, 정상·exec 실패·쓰기 실패 세 경로에서 파일이 **정확히 한 번**만 지워지는 것도 확인했다. 비차단 관찰 1건(I1).
|
||||
|
||||
## 1. R1/H1 — 러너의 쓰기 실패 (항목 1)
|
||||
|
||||
`runner.py:472-491`: `mkdir`·`NamedTemporaryFile`·`write` 가 하나의 `try` 로 들어가고, `prompt_file` 은 `""` 로 초기화된 뒤 파일이 생기는 즉시(`write` 이전) 이름을 받는다. `except OSError` 는 이름이 있으면 지우고 `CompletedProcess(…, 126, "", "cannot write the prompt file under <mam_dir>: <err> (prompt N bytes)")` 를 돌려준다. exec 용 `try/finally`(`:504-516`)는 그 뒤에 따로 있어 두 블록이 겹치지 않는다.
|
||||
|
||||
### 실측 (검증 경로 러너 in-process)
|
||||
|
||||
| 경로 | 결과 |
|
||||
|---|---|
|
||||
| **쓰기 실패**(1회차 H1 재현 스크립트 그대로: `write` 가 `OSError(28)`) | `delegate_job` 이 **None** 을 반환(예외 없음), 로그 `cannot write the prompt file under …: [Errno 28] No space left on device (prompt 5 bytes)`, `.mam` 잔존 **0**, 래퍼 호출 **0** |
|
||||
| 정상 | 래퍼 호출 시점에 파일 존재·내용 `'hello'`·권한 **0600**, `os.unlink` 호출 **1회**, 잔존 0 |
|
||||
| exec 실패(`subprocess.run` 이 `OSError(2)`) | rc 126 경로, `os.unlink` 호출 **1회**, 잔존 0, 반환 None |
|
||||
|
||||
- 반환하는 `CompletedProcess` 는 exec 실패와 같은 rc 126 이라 기존 처리(`submit failed (exit 126)` 로그, busy 재시도 없음, id 없음 → `failed_submit_jobs` 미등록·None 반환 → 호출자 `return 1`)에 그대로 합류한다. S4c 가 `submit failed (exit 126)`·`cannot write the prompt file`·`Traceback` 부재·래퍼 미실행·잔존 없음을 한꺼번에 단언한다.
|
||||
- `NamedTemporaryFile` 생성 자체가 실패하면 `prompt_file` 이 빈 채로 `except` 에 들어가 `unlink` 를 건너뛴다 — 지울 것이 없으니 맞다. `mkdir` 실패도 같은 블록이다.
|
||||
|
||||
## 2. H3 — 빈 `--prompt-file` 거부 (항목 2)
|
||||
|
||||
`multi-agent-mux-delegate-job:193` 의 `[[ -s "$PROMPT_FILE" ]]` 는 **래퍼 `submit` 의 입력**에만 걸린다. 정당한 빈 프롬프트 사용처를 막는지 실측:
|
||||
|
||||
| 경로 | 결과 |
|
||||
|---|---|
|
||||
| 래퍼 `submit --prompt-file empty.md` | `submit: --prompt-file <절대경로> is empty`, rc 1 (S1e) |
|
||||
| 래퍼 `submit --prompt-file newline.md`(개행 1바이트) | 등록됨 — `-s` 는 바이트 크기 기준이라 공백만 있는 파일은 통과한다(I1) |
|
||||
| 래퍼 `submit --prompt ""` | 종전대로 `submit requires --prompt or --prompt-file` 거부 — 텍스트 형식과 대칭 |
|
||||
| `registry.py register --prompt-file empty.md`(직접) | 등록됨(rc 0) — 등록부는 그대로 |
|
||||
| loop 형 회차의 `update --prompt-file "$job_dir/prompt.md"` | `cmd_submit` 을 거치지 않으므로 검사 밖(변경 없음) |
|
||||
|
||||
즉 새 검사는 "빈 텍스트를 거부하던" 기존 규칙을 파일 형식으로 확장한 것이고, 등록부·loop 회차 경로는 건드리지 않는다. 막히는 정당한 사용처는 없다.
|
||||
|
||||
## 3. R2/N1 — S9 의 macOS 이식성
|
||||
|
||||
`sys.platform == "linux"` 일 때만 `File too large` 를 단언하고, rc≠0·`prompt-shloop-*` 잔존 없음·`delegate_job_safe: cannot write the prompt file` 은 모든 플랫폼에서 단언한다. 1회차에서 내가 하네스를 직접 돌려 확인한 macOS 동작(rc 1·잔존 없음·원인 문구 출력)과 정확히 일치하는 계약이다.
|
||||
|
||||
## 4. 테스트 (항목 1·3)
|
||||
|
||||
### 4-1. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 `runner.py` 와 래퍼만 `59be1c8` 판으로 되돌리고 `-k "s4c or s1e or s9 or s4 or s1"` 을 돌렸다:
|
||||
|
||||
```
|
||||
2 failed, 6 passed, 1 skipped in 3.11s
|
||||
FAILED test_s4c_a_prompt_file_that_cannot_be_written_is_a_failed_submit ← H1
|
||||
FAILED test_s1e_an_empty_prompt_file_is_refused ← H3
|
||||
```
|
||||
|
||||
S9 는 되돌린 사본에서도 통과한다 — `run_loop.sh` 쪽 수정은 이미 `59be1c8` 에 있었고 이번 델타는 테스트의 단언만 이식 가능하게 바꿨기 때문이다(macOS 에서 통과 = R2 정정 확인). S1/S1c/S1d/S4/S4b 는 양쪽 모두 통과(불변 조건).
|
||||
|
||||
### 4-2. 검증 경로에서의 실행
|
||||
|
||||
```
|
||||
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_loop_cli.py
|
||||
tests/test_issue14_delivery_ack.py tests/test_issue25_plan_reuse.py
|
||||
→ 127 passed, 1 skipped in 215.94s failed 0 (skip = linux_only S1b)
|
||||
```
|
||||
|
||||
1회차의 유일한 실패였던 S9 가 이 호스트에서 통과하고, 새 파일 24건(23 통과 + Linux 전용 skip 1)과 인접 계약 스위트가 모두 통과한다.
|
||||
|
||||
### 4-3. 격리
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs` 전후 | **차이 0** |
|
||||
| `prompt-*` 잔존(실제 `.mam` / 검증 경로 `.mam`) | **0 / 0** |
|
||||
| 검증 경로 tracked 파일 | 변화 없음(HEAD `708262a`), 본 체크아웃 `main 1debc7d` |
|
||||
|
||||
## 5. 병합 차단 여부 (항목 3)
|
||||
|
||||
1회차 PASS 를 뒤집을 요소는 없다. 델타는 러너의 `try` 범위 확장(`runner.py:472-491`), 래퍼의 `-s` 한 줄(`:193`), 테스트 3건(S4c·S1e·S9 조건부 단언)뿐이고, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 H2(loop SKILL.md 의 `MAM_DIFF_MAX_*` 의미)·H4(`$PROMPT` 후행 개행)는 이 델타 범위 밖이라 그대로 열려 있다.
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| I1 | `-s` 는 바이트 크기 검사라 개행·공백만 있는 프롬프트 파일은 통과한다. 텍스트 형식(`--prompt ""`)의 거부와 완전히 대칭이려면 `[[ -n "$(tr -d '[:space:]' < "$PROMPT_FILE")" ]]` 류가 필요하지만, 실무에서 그런 입력은 없다 | `multi-agent-mux-delegate-job:193`, 실측 |
|
||||
|
||||
## 6. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T085626Z-b8987e/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 708262a… / d601c2d…
|
||||
# H1 재현 스크립트(1회차 §1 의 것)를 정정된 러너에 → returned None, leftover [], wrapper called 0
|
||||
# 되돌린 사본: runner.py·래퍼를 59be1c8 판으로 → -k "s4c or s1e or s9 or s4 or s1" → 2 failed / 6 passed / 1 skipped
|
||||
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue36_prompt_transport.py) # 23 passed, 1 skipped
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T015801Z-05463c",
|
||||
"base_sha": "1d7b7dab58a10fe86fd747b7290d47674ca93230",
|
||||
"head_sha": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"tree_sha": "87013e7ef7f5239f1bf6ea38a2cbda214257be3c",
|
||||
"review_commit": "6a37c0019baa31ee28b32a086e57b4c8239413c4",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "1fb2cefa",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T02:37:22Z"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# PR #62 리뷰 2회차 — Issue #60 Linux 확인 후속 (job 1fb2cefa)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T015801Z-05463c/1`
|
||||
- identity: HEAD `6a37c0019baa31ee28b32a086e57b4c8239413c4`, tree `87013e7ef7f5239f1bf6ea38a2cbda214257be3c` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||
- 범위: base `1d7b7da`..head 9커밋. 판정은 base..head 전체에 대한 것이다. 1회차 이후 바뀐 것은 6a37c00 하나이고, `opencode.py`·`status.sh`·`reconcile.sh`·`test_issue60_linux_fixes.py` 4개 파일만 바뀌었다.
|
||||
- 1회차(@6a4e385)의 확인은 6a37c00 이 건드리지 않은 코드라 그대로 유효하다. 1회차에 확인한 것: 임시 DB 에서 `_ro_connect` 세 갈래, `_DB_PATHS` 키, `auth-ok` CLI, stop 문구.
|
||||
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c`·`ps`·`lsof` 로만 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
1회차의 공통 차단(B1)과 codex·agy 지적이 모두 해결됐고, 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다.
|
||||
|
||||
## 1회차 지적별 확인
|
||||
|
||||
**B1 (clean 상태 5개가 macOS 에서 실패) — 해결.**
|
||||
- `make("clean")` 이 close 뒤 남은 `-wal`·`-shm` 을 지운다(`tests/test_issue60_linux_fixes.py:158-162`). 1회차 사본에서 내가 검증한 수정과 같다.
|
||||
- 검증 경로에서 이 모듈이 모두 통과한다(아래 실행 결과).
|
||||
- 뮤테이션 i1(immutable 분기를 `mode=ro` 로)이 이제 이 Mac 에서 5 failed 로 잡힌다. 1회차에는 fixture 가 먼저 실패해서 이 뮤테이션이 가려졌었다.
|
||||
|
||||
**안내 문구 패턴이 사용자가 친 `Ask anything…` 를 지움(codex) — 해결.**
|
||||
- 패턴이 `Ask anything(?:…|\.\.\.) "[^"]*"?` 가 되어 따옴표 예시가 필수다(`opencode.py:80`).
|
||||
- 두 fixture 에 넣어 직접 확인한 결과:
|
||||
- `Ask anything…`, `Ask anything...` 는 친 그대로 읽힌다.
|
||||
- 홈 화면 원본, 잘린 예시, `Ask anything… "` 는 빈 입력으로 읽힌다.
|
||||
- 회귀 테스트 2개가 추가됐다(`:84-85`). 뮤테이션 p1(예시를 다시 선택 사항으로)은 4 failed 로 잡힌다.
|
||||
|
||||
**auth_ok 가 실행 불가한 `~/.opencode/bin/opencode` 도 인정(codex) — 해결.**
|
||||
- 이제 `isfile and access(X_OK)` 로 본다(`opencode.py:322-323`).
|
||||
- 권한 644 파일과 디렉터리는 모두 False 로 고정됐다(`test_a_home_install_that_cannot_run_is_not_enough`). 뮤테이션 a1(`exists` 로 되돌림)은 1 failed 로 잡힌다.
|
||||
- `shutil.which` 가 이미 실행 가능 여부를 보므로 PATH 쪽은 그대로다.
|
||||
|
||||
**status.sh·reconcile.sh 의 `sqlite3.connect(odb)`(agy) — 해결.**
|
||||
- status 의 opencode 두 블록(`status.sh:115-117`, `:316-318`)과 reconcile drift C(`reconcile.sh:953-955`)가 `_ro_connect` 를 쓴다.
|
||||
- 두 스크립트는 lib_py 를 이미 import 하는 경로에서 돌기 때문에 import 가 된다(status 는 `env_python`/PYTHONPATH, reconcile 은 `env_python`).
|
||||
- status 블록에서 `import sqlite3` 를 지웠지만 영향이 없다. 같은 함수의 다른 블록(hermes `:90`, codex `:132`)은 각자 `import sqlite3` 를 한다.
|
||||
- 행동 확인: git archive 사본에 임시 e2e 를 넣었다. 순서는 이렇다.
|
||||
1. mam_sandbox+mock 에서 create → 자식 세션 추가 → `journal_mode=wal` → 사이드카 삭제(Linux 의 닫힌 상태)
|
||||
2. `reconcile.sh --once --emit-diff` → `status.sh --json` 과 plain status
|
||||
- 결과(head): rc 는 모두 0. 부모 id 로 고정됐고 `resume_state == "yes"` 이다. DB 디렉터리는 실행 전후 모두 `['opencode.db']` 였다.
|
||||
- 대조(두 스크립트를 `sqlite3.connect(odb)` 로 되돌린 사본): reconcile 뒤 `opencode.db-shm`·`opencode.db-wal` 이 생겨 같은 테스트가 실패했다.
|
||||
- 참고: mock opencode 가 만드는 DB 는 WAL 모드가 아니다. 그래서 WAL 로 바꾸지 않으면 이 차이가 드러나지 않는다.
|
||||
|
||||
## 테스트 실행 (검증 경로)
|
||||
|
||||
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
- 대상 12개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, tier1, tier2, issue31, issue55, issue57, issue21_worktree_prereqs, issue50_dispatch, issue30_stall_watch, c1_tui_readiness.
|
||||
- 결과: **463 passed, 2 skipped, 실패 0**, 16분.
|
||||
- skip 1: `/proc` 전용 테스트.
|
||||
- skip 2: PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트(1회차·이전 이슈에서 따로 확인함).
|
||||
- 부작용 확인:
|
||||
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||
- 실제 `~/.local/share/opencode` 의 목록은 전후가 같았다.
|
||||
- 실제 log 의 `debug` 줄 수는 2 → 2 였다.
|
||||
- 남은 opencode 프로세스는 사용자 서비스 하나뿐이다(pid 23630, 시작 08:13, 이 잡 이전).
|
||||
- #55 가드 오탐은 없었다.
|
||||
- 린트:
|
||||
- `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
|
||||
- 바뀐 Python 은 3.9 로 파싱된다.
|
||||
- `status.sh`·`reconcile.sh` 는 `/bin/bash -n`(3.2)을 통과한다.
|
||||
|
||||
## 비차단
|
||||
|
||||
- **N1.** 새 `test_status_and_reconcile_open_the_database_read_only` 는 소스 문자열만 센다. `sqlite3.connect(odb, timeout=…)` 같은 변형은 지나간다. 위의 임시 e2e(WAL DB, 사이드카 없음 → reconcile·status → 목록 불변)를 tier2 opencode e2e 에 붙이면 행동으로 고정할 수 있다.
|
||||
- **N2.** 1회차 비차단은 의도된 완화로 그대로 남아 있다(브리프에도 기록됨):
|
||||
- immutable 경쟁 창(ms 수준)
|
||||
- `_DB_PATHS` 의 프로세스 수명 캐시
|
||||
- create 사전 검사의 완화
|
||||
- **N3.** 아주 좁은 화면에서 예시의 여는 따옴표까지 잘리면 `Ask anything…` 만 남는다. 이때는 친 초안으로 읽힌다(안전한 쪽). 실제 캡처로는 확인하지 않았다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,51 @@
|
||||
# 리뷰 리포트 — PR #59 (Issue #31, claude purge 잔여물) / job 207c65b8
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
|
||||
- identity: HEAD `6f4f40a4adb2115d5997dffd31fb6473f05cab68`, tree `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8` — 일치. 트리 clean, `.mam` 없음(테스트 실행 후에도 동일).
|
||||
- 범위: base 26e2c43 .. head 6f4f40a (13 커밋, 12 파일)
|
||||
|
||||
## 요약
|
||||
|
||||
설계와 대부분의 구현은 이 Mac에서 맞게 동작한다. 실제 `~/.claude` 구조, BSD `ps -o lstart=`, `/tmp → /private/tmp` 스크래치패드 레이아웃이 어댑터 가정과 일치하고, 다른 세션·공유 파일·심볼릭 링크 너머는 지우지 않았다. 다만 **삭제 범위 가드(`_plausible_id`)가 UUID가 아닌 id를 거르지 못해, id `memory` 로 purge 하면 `projects/<key>/memory/`(Claude Code 자동 메모리 디렉터리)가 통째로 지워진다**(F1). 이 PR이 `<id>.jsonl` 삭제에서 `<id>` 디렉터리 rmtree 로 범위를 넓히면서 생긴 회귀이고, 브리프 중점 항목("잘못된 uuid 거부")에 해당하므로 NOT PASS 로 판정한다. 수정은 한 줄 수준이다.
|
||||
|
||||
## 차단 결함
|
||||
|
||||
### F1. 비-UUID id `memory` 가 자동 메모리 디렉터리를 지운다
|
||||
|
||||
- 위치: `.agents/skills/lib_py/agents/base.py:89-92` (`_plausible_id`: 빈 문자열·점만·`/`·`..`·`*` 만 거부), `.agents/skills/lib_py/agents/adapters/claude.py:262-268` (`base = projects/<key>/<uuid>`, 디렉터리면 `_remove` → `shutil.rmtree`), `claude.py:287-289` (`has_artifacts` 가 같은 목록을 써서 `purge auto` 도 claude 로 해석).
|
||||
- 실제 이 Mac 의 `~/.claude/projects/<key>/` 에는 세션 디렉터리와 나란히 `memory/` 가 있다(읽기만 확인: `projects/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/memory/`). 세션 id 와 같은 층에 있는 유일한 비-UUID 이름이다.
|
||||
- 재현 1(어댑터 직접, 샌드박스 HOME/CLAUDE_PROJECT_DIR/CLAUDE_CODE_TMPDIR/TMPDIR, `/usr/bin/python3` 3.9.6):
|
||||
`has_artifacts("memory") → True`, `purge_artifacts("memory") → [.../projects/<key>/memory, .../file-history/memory]`, `MEMORY.md survives: False`.
|
||||
- 재현 2(CLI): `python -m lib_py.agents purge claude memory <ws>` → 이 호스트에서는 다른 프로세스 29개의 argv 에 "memory" 가 들어 있어 우연히 in-use(rc 4)로 거부됐다. argv 에 "memory" 가 없는 호스트를 흉내 내려고 `ps` 출력에서 그 줄만 거르는 스텁을 PATH 앞에 두면 rc 0, `"purged": [".../projects/<key>/memory"]`, `memory survives: False`.
|
||||
(스크립트: `/private/tmp/claude-501/-Users-godopu16-PuKi-laa-canary-projects-multi-agent-mux/8cb38cbf-0d61-4d78-8c7e-065e409ce13d/scratchpad/j207/probe_mem.py`)
|
||||
- 도달 경로: 스킬 진입점은 안전하다 — `orc_onboard.sh:88-95` 은 정규식으로 UUID 를 강제하고, `stop_session.sh` 의 `PURGE_UUID` 는 `find_workspace_uuid` 가 `<id>.jsonl` 의 sessionId 일치로 검증한 값이다. 위험한 경로는 직접 쓰는 `lib_py.agents purge`/어댑터 API(VERSIONS.md 에 문서화)다. 그러나 이 PR 이전에는 같은 입력이 존재하지 않는 `memory.jsonl` 만 겨냥해 무해했다. 지워지는 것은 복구할 수 없는 사용자 메모리이고, 테스트(`tests/test_issue31_purge_residual.py:242` 의 `bad` 목록)도 이 경우를 다루지 않는다.
|
||||
- 제안: claude 어댑터의 `_owned_paths`/`residual_artifacts`(또는 `purge_artifacts`·`has_artifacts` 입구)에서 정규 UUID(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 대소문자 무시)만 받게 하고, `bad` 목록에 `"memory"` 와 UUID 가 아닌 평범한 이름을 추가하는 회귀 테스트. 다른 어댑터는 이름 스캔(`scan_names`)·보고만 하므로 공용 `_plausible_id` 는 그대로 둬도 된다.
|
||||
|
||||
## 확인한 사항 (정상)
|
||||
|
||||
1. **BSD `ps -o lstart=`**: 이 Mac 출력은 `Fri Oct 16 08:25:14 2026` (LC_ALL=C 와 기본 로캘 동일). `" ".join(out.split())` + `%a %b %d %H:%M:%S %Y` 로 파싱된다. 실제 살아 있는 claude 4개의 `sessions/<pid>.json` 에서 `startedAt − lstart` = 0.69 / 1.14 / 1.93 / 2.43 s → 모두 live 판정(`lstart <= startedAt+2`). 샌드박스 `sleep` 프로세스로: 방금 쓴 startedAt → in-use rc 4(`--wait 2` 로 2.2 s 대기 후), purge CLI rc 4, jsonl 보존; 프로세스 종료 후 rc 0. startedAt 을 1시간 전으로 둔(pid 재사용) 경우 → free, 그 세션 파일만 삭제. `ps` 실패·빈 출력·파싱 실패·`startedAt` 없음 → live(`claude.py:66-74`).
|
||||
2. **tmp 루트**: TMPDIR=`/var/folders/.../T/` 아래에는 `claude-501` 이 없고, 실제 스크래치패드는 `/private/tmp/claude-501/<key>/<uuid>/{scratchpad,tasks}` 다. 후보에 항상 `/tmp/claude-<uid>` 가 들어가고(`claude.py:41`) realpath 로 `/private/tmp` 와 중복 제거된다. `<key>` 는 계산하지 않고 루트 아래 모든 키를 나열해 `<k>/<uuid>` 만 고르므로 키 규칙 차이에 영향받지 않는다. 샌드박스에서 CLAUDE_CODE_TMPDIR·TMPDIR 양쪽 루트의 `<uuid>` 만 지워졌다.
|
||||
3. **실제 `~/.claude` 구조**(읽기만): `sessions/<pid>.json`(int `pid`, ms `startedAt`, `sessionId`) + `<pid>.<64hex>.key`, `jobs/<8hex>/state.json`(sessionId[:8] = 디렉터리명), `file-history/<uuid>/`, `session-env/<uuid>/`, `projects/<key>/<uuid>/{tool-results,…}` — 어댑터 가정과 일치.
|
||||
4. **삭제 범위**(샌드박스, `/usr/bin/python3`): 같은 jobs 접두사의 다른 세션, 다른 세션의 sessions json·key, 같은 pid 접두사이지만 64hex 가 아닌 `.key`, `history.jsonl`, 다른 uuid 의 file-history·jsonl·스크래치패드가 모두 보존됐다. `session-env/<U>/link → 외부`, 스크래치패드 안의 `linkout → 외부` 심볼릭 링크는 링크만 지워지고 대상 파일은 남았다. 재실행 → rc 5(남은 것 없음). `""`/`.`/`..`/`...`/`a/b`/`*`/`../x` → 아무것도 지우지 않음(rc 1 또는 5).
|
||||
5. **stop exit 4 계약**: 게이트(`stop_session.sh:285-300`)는 레지스트리 잠금 밖, YAML 쓰기 전에 실행되고 0 이외는 모두 exit 4 + cancelled 이벤트. `_delegate_py_bin` 가 `/usr/bin/python3` 로 떨어져도 `-m lib_py.agents in-use` 는 paho 없이 동작한다(직접 확인). bash 3.2 `-n` 통과, `inuse_rc=0; … || inuse_rc=$?` 는 `set -u` 에서 안전.
|
||||
6. **residual 보고**: purge 후 `residual_artifacts` 가 빈 목록이면 `residual: none (agent: …)`. 실제 `~/.claude` 에 대한 이름 스캔(읽기 전용, 가짜 uuid)은 0.02 s.
|
||||
7. **Python 3.9 / bash 3.2**: 변경된 .py 8개 모두 `feature_version=(3,9)` 로 파싱되고, 프로브는 전부 `/usr/bin/python3` 3.9.6 으로 실행됐다(registry 가 모든 어댑터를 import). `lib.sh`·`stop_session.sh` 는 `/bin/bash` 3.2.57 `-n` 통과.
|
||||
8. **문서·skill_code_blocks.json**: stop SKILL.md 의 "purge 는 행을 지운다"·exit 4·삭제 목록이 코드와 맞고, `test_issue42_skill_blocks.py` 통과(sha·passthrough 라인 갱신 정합).
|
||||
9. **codex·grok·opencode residual**: 이름 스캔 + `mode=ro` SQLite 조회만 하므로 쓰기 없음.
|
||||
|
||||
## 테스트
|
||||
|
||||
- 검증 경로에서 `env -i …`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), `--basetemp` 는 스크래치패드, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`:
|
||||
`test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target` → **188 passed, 1 skipped** (276 s). 실행 후 트리 clean.
|
||||
- 사전 수정 대조: `git archive HEAD` 복사본에서 제품 파일 8개를 base 26e2c43 로 되돌리고 `test_issue31_purge_residual.py` 실행 → **24 failed, 8 passed**. 통과한 8개는 "다른 세션을 건드리지 않음"·"잘못된 uuid 거부"처럼 이전 코드에서도 성립하는 안전성 테스트로, 기대한 결과다. stop 게이트 테스트(`test_stop_purge_refuses_while_the_claude_process_lives`)와 pid 재사용 테스트도 사전 코드에서 실패한다.
|
||||
|
||||
## 비차단 메모
|
||||
|
||||
- N1. DST: `time.mktime(time.strptime(...))` 는 `tm_isdst=-1` 이라 서머타임이 끝나는 겹치는 1시간에는 lstart 가 1시간 늦게 해석될 수 있고, 그러면 살아 있는 세션을 stale 로 볼 수 있다(fail-open). 이 Mac 은 KST(서머타임 없음)라 영향 없음. 실제 세션 파일에 `procStart`(UTC 문자열, `pidDomain: "darwin"`)가 있으니 이를 쓰거나 비교를 UTC 로 하는 것도 고려할 만하다.
|
||||
- N2. stop 의 exit 4 메시지(`stop_session.sh:296`)는 `~/.claude/sessions/` 만 가리킨다. CLAUDE_CONFIG_DIR 을 쓰면 위치가 다르고, argv 에 uuid 를 담은 아무 프로세스(에디터·grep·위임 래퍼)도 거부 사유가 된다. orc_onboard SKILL.md 처럼 `ps -axww | grep <uuid>` 안내를 함께 두면 좋다.
|
||||
- N3. SKILL.md exit 4 행의 "(nothing was changed)" — 프로세스 게이트는 herdr kill 이후에 돌므로 세션은 이미 내려가 있을 수 있다(레코드는 그대로). 기존 herdr 게이트 문구와 같은 뉘앙스이지만 "records preserved" 가 더 정확하다.
|
||||
- N4. stop 이 이제 모든 에이전트에 in-use 게이트를 적용한다. agy 처럼 CLI 종료 뒤에도 다른 프로세스가 DB/락을 열어 두는 구성이면 이전에는 purge 되던 것이 exit 4 로 바뀐다. 의도된 fail-closed 이지만 실제 agy 로는 확인하지 못했다(브리프상 실제 에이전트 실행 금지).
|
||||
- N5. 테스트는 하드코딩된 `/tmp/claude-<uid>` 후보를 격리하지 못한다(실제 `/private/tmp/claude-501` 을 나열). 무작위 uuid 라 실제로 지워지는 것은 없지만, `_tmp_roots` 를 테스트에서 패치할 수 있게 하면 더 안전하다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,44 @@
|
||||
# 리뷰 리포트 — Job 24b1f07f (Issue #17 후속: 테스트의 상속 WORKSPACE_ROOT, 6b00b6e..d198dd5)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head d198dd5 / tree 114ec21 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 1개(d198dd5), tests/test_issue17_item4_decisions.py 만(+36/−2). 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
할 일 1·3·4 가 요구대로 반영됐고, 새 회귀 테스트는 수정 전 `_env` 에서 실제로 실패한다(센티널에 `.mam/shim/herdr` 생성). 할 일 2(다른 테스트 조사)는 이 PR 에 코드 변경이 없는데, 내가 직접 grep 한 결과로도 **같은 결함(다른 워크스페이스에 쓰기)은 conftest 가 이미 프로세스 수준에서 막고 있어** 추가 수정 대상이 없다 — 다만 남는 두 파일(§3)은 creator 리포트의 표에 있어야 한다. 차단 결함 없음.
|
||||
|
||||
## 1. 할 일 1 — `_env` (tests/test_issue17_item4_decisions.py:224-233)
|
||||
- 상속 제외 목록에 `WORKSPACE_ROOT` 추가(:229), `env.update(... WORKSPACE_ROOT=str(tmp_path) ...)`(:231) 로 테스트별 임시 경로를 명시. "제거"가 아니라 "지정"을 고른 이유는 주석(:225-227)에 있고 맞다: lib.sh:26 은 `WORKSPACE_ROOT` 가 비어 있으면 `$SKILL_DIR/../..` = **체크아웃 자신**을 쓰므로, 제거만 하면 래퍼를 source 하는 헬퍼 4곳(_run_both :246/:372/:561/:586 등)이 체크아웃의 `.mam/shim/herdr` 를 다시 쓴다. 지정하면 shim 이 tmp_path 아래에 생긴다(회귀 테스트가 `(work/.mam/shim/herdr).is_file()` 로 고정, :498).
|
||||
- `extra` 로 넘기는 명시 WORKSPACE_ROOT(:692 의 wait-rc 테스트)는 `env.update(extra)` 가 뒤에 오므로 그대로 이긴다 — 의도적 지정 테스트가 깨지지 않는다.
|
||||
|
||||
## 2. 할 일 3 — 회귀 테스트 test_c_helpers_write_nothing_to_an_inherited_workspace (:477-498)
|
||||
- 센티널 디렉터리를 `WORKSPACE_ROOT` 로 monkeypatch 한 뒤 `_run_both`(옛·새 양쪽)와 래퍼 직접 source 를 돌리고, 센티널 아래 파일 없음·체크아웃 shim mtime 불변·셸이 보는 `$WORKSPACE_ROOT` 가 테스트 tmp 임·shim 이 tmp 에 생김을 단언한다.
|
||||
- **수정 전 코드에서 실패 확인**: HEAD 의 테스트 파일에서 `_env` 두 줄만 base(6b00b6e) 판으로 되돌린 스크래치 사본 → `AssertionError: written into the sentinel`, `['.mam', '.mam/shim', '.mam/shim/herdr'] == []` (1 failed). 현재 코드에서 통과.
|
||||
- conftest 의 `pytest_configure` 는 다른 체크아웃을 가리키는 WORKSPACE_ROOT 를 수집 전에 지우므로(tests/conftest.py:139-148), 이 테스트가 monkeypatch 로 다시 설정하는 것이 맞는 설계다(지우지 않으면 센티널이 보이지 않는다).
|
||||
|
||||
## 3. 할 일 2 — 같은 결함이 다른 테스트에 있는가 (직접 grep)
|
||||
- **conftest 가 막는 범위**: `pytest_configure`(conftest.py:139-148)가 AGENT_SESSIONS_YAML·MAM_JOBS_DIR·DELEGATE_JOB_LOGS_DIR 을 항상, `WORKSPACE_ROOT` 는 **realpath 가 이 체크아웃이 아닐 때만** os.environ 에서 지운다(주석 :122-124: 이 체크아웃을 가리키면 "lib.sh 가 어차피 유도하는 값"). 따라서 어떤 테스트든 os.environ 을 복사해 lib.sh 를 돌려도 **다른 워크스페이스에는 쓸 수 없다**. 남는 경우는 (i) 체크아웃 자신의 shim 재작성, (ii) 테스트가 monkeypatch 로 다시 설정한 값, (iii) pytest 밖에서 헬퍼를 직접 돌리는 경우(PR #51 리뷰 때의 사고는 리뷰어의 수동 탐침, 즉 (iii)).
|
||||
- **이 파일이 "빠진" 이유**: 빠진 것이 아니라 (i)(iii) 에 노출돼 있었다 — `_env` 가 WORKSPACE_ROOT 를 남겨 둔 채 래퍼→lib.sh 를 source 했으므로, 스위트 실행 중에는 체크아웃 자신의 shim 을 매번 다시 썼고, 수동 실행에서는 상속값 그대로 썼다. 이번 수정이 둘 다 막는다.
|
||||
- os.environ 을 복사하고 lib.sh/래퍼를 돌리는 테스트 파일 36개를 grep 했다. `mam_sandbox` 를 쓰는 파일은 fixture 가 `WORKSPACE_ROOT=tmp_path` 를 설정하므로(conftest.py:215) 복사본이 sandbox 를 물려받아 안전하다. fixture 없이 lib.sh 를 실제로 source 하는 파일은 둘뿐이며, 둘 다 (i) 에만 해당한다:
|
||||
|
||||
| 파일:라인 | 동작 | 판단 |
|
||||
|---|---|---|
|
||||
| tests/test_b19_headless_reconcile_fixes.py:326-329, :366-369, :401-402 | `source "$SKILL_DIR/lib.sh"` 하는 스크립트를 SKS_* 만 뺀 os.environ 으로 실행 | 다른 체크아웃은 conftest 가 지움; 이 체크아웃이면 자신의 shim 재작성(conftest 가 명시적으로 허용하는 경우). 고치지 않아도 되나 표에 있어야 함 |
|
||||
| tests/test_version_consistency.py:30 | `bash -c 'source lib.sh …'` 를 env 지정 없이 cwd=REPO_ROOT 로 실행 | 위와 같음 |
|
||||
| tests/test_deploy_freshness.py, test_deploy_layout.py | lib.sh 를 복사·읽기만, source 안 함 | 해당 없음 |
|
||||
- 제품 코드 변경 필요 여부: 없음. conftest 한 곳에서 "이 체크아웃" 경우까지 지우는 방법은 lib.sh 가 어차피 같은 값을 유도하므로 효과가 없고(주석 :122-124 의 근거), 체크아웃 shim 재작성을 피하려면 각 헬퍼가 tmp 를 지정해야 한다 — 이 PR 의 방식이 맞다.
|
||||
|
||||
## 4. 할 일 4 — 실행 (검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| (a) WORKSPACE_ROOT 없이: test_issue17_item4_decisions, test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **443 passed / 0 failed** (478 s) |
|
||||
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: test_issue17_item4_decisions | **290 passed / 0 failed** (319 s); 실행 뒤 센티널 **비어 있음**(`find` 출력 없음) |
|
||||
| 회귀 테스트, 수정 전 `_env` 사본 | 1 failed(센티널에 .mam/shim/herdr) |
|
||||
- 본 체크아웃 `.mam/shim/herdr` mtime 실행 전후 동일(1791469013). 실제 .mam/jobs 항목 수 불변. 브로커 bind 제약 없음.
|
||||
- (b) 에서 센티널은 realpath 가 체크아웃이 아니라 conftest 가 지우므로, 대부분의 테스트는 conftest 로, 회귀 테스트는 `_env` 로 보호되는 셈이다 — 둘 다 통과했으니 브리프 (b) 의 목적(어느 층이든 센티널에 쓰지 않음)은 충족된다.
|
||||
|
||||
## 5. 이식성·비차단
|
||||
- 변경은 Python 테스트 코드뿐(3.9 문법, `stat().st_mtime_ns`, `Path.rglob`). bash 3.2·macOS 영향 없음.
|
||||
- 비차단: §3 표의 두 파일이 creator 리포트의 "고치지 않은 후보" 표에 없다면 추가를 권한다. 새 관찰 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,170 @@
|
||||
# 🔍 Review Report — PR #29 병합 전 독립 재검토 (job `291e38b5`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-20
|
||||
- **대상**: PR #29 `feat/issue-21-items-4-5-7` **@ `46ea4cc`**, base `main @ 1134d74` (v4.6.0). 20커밋, `git diff --stat 1134d74...HEAD` = **25 files, +3229/−108**.
|
||||
- **검토 트리**: 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `46ea4cc`, 자체 `.venv`).
|
||||
- **안전 규칙 준수**: 워크트리에 파일 수정·커밋·checkout 없음. 본 체크아웃은 `main @ 1134d74` 그대로(checkout/switch/merge 없음). `worktree_remove.sh` 를 실제 워크트리에 실행하지 않았다. 실제 `.mam`·monitor 데몬·에이전트 세션 조작 없음, push·merge·이슈/PR 상태 변경 없음. 재현 실험은 scratchpad 의 임시 저장소(uuid4 이름)에서만 했다.
|
||||
- **heartbeat**: 브리프 직후 `started --heartbeat 30` 을 가장 먼저 발행했다.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
브리프의 8개 항목을 모두 확인했다. **병합을 막을 결함은 찾지 못했다.** 항목 5(리뷰 identity)의 설계·구현 품질은 높고, 기본 경로(옵션 없는 루프)의 호환성은 코드상 보장된다. 아래 **비차단 지적 9건**(N1–N9)을 남긴다. 그중 N1 은 재현 실험으로 확인한 실제 소실 가능성(판정 증거의 gc 소멸)이므로 후속 이슈 등록을 권한다.
|
||||
|
||||
## 1. 항목 4 — 워크트리 준비·해체 헬퍼
|
||||
|
||||
### 1-1. `.venv` — 브리프가 물은 "실제 venv" 는 아니다 (N2, 비차단)
|
||||
|
||||
`worktree_prepare.sh:111-118` 은 여전히 **심링크**를 만든다(`ln -s "$MAIN/.venv" "$WT/.venv"`). 실제 venv 생성도, 인터프리터 경로 전달도 하지 않는다. 다만 이번 PR·base 에서 **커밋 사고 경로는 3중으로 닫혀 있다**:
|
||||
|
||||
| 층 | 근거 |
|
||||
|---|---|
|
||||
| 저장소 `.gitignore` | base `1134d74` 의 `.gitignore:15` 가 `.venv`(슬래시 없음) — 파일·링크·디렉터리 모두 무시 |
|
||||
| 공유 `info/exclude` | `worktree_prepare.sh:120-129` 가 `/.venv`·`.pytest_cache/` 를 `$GIT_COMMON_DIR/info/exclude` 에 추가 |
|
||||
| 배포 관리 블록 | `deploy/install.sh:549`·`deploy/install_mam.sh:249` 가 `/.venv/` → `/.venv` (#27 `fadd38c`) |
|
||||
|
||||
남는 위험은 두 가지이며, 둘 다 헬퍼가 유발하지는 않는다.
|
||||
- `rm -rf "$WT/.venv/"` 처럼 **끝에 슬래시를 붙이면 링크를 따라가** 제어 루트의 venv 내용을 지운다. 헬퍼는 그런 호출을 하지 않지만(스크립트 2개의 `rm -rf` 는 `$gd/modules*` 뿐), 사람·에이전트의 수작업에서는 그대로 남는 함정이다.
|
||||
- 워크트리와 본 체크아웃이 venv 를 공유하므로 브랜치별 의존성 분기가 불가능하다.
|
||||
|
||||
→ 권고: 헬퍼에 `--venv link|create` 같은 선택지를 두거나, 최소한 SKILL.md 에 "`.venv` 는 링크이므로 워크트리에서 `pip install`·`rm -rf .venv/` 금지"를 명시.
|
||||
|
||||
### 1-2. `info/exclude` 는 공유물이다 (N3, 비차단)
|
||||
|
||||
linked worktree 의 `info/exclude` 는 `$GIT_COMMON_DIR` 의 것이므로 prepare 가 **본 체크아웃의 무시 규칙도 바꾼다**. 코드 주석(`:120-122`)과 설계 문서가 이를 명시하고, 추가되는 두 패턴은 본 체크아웃에서도 산출물이다. 수용 가능하나, 되돌리는 경로(remove 시 정리)는 없다.
|
||||
|
||||
### 1-3. 서브모듈 opt-in·롤백
|
||||
|
||||
`worktree_prepare.sh:131-204`: `--submodules` 일 때만 초기화하고, 초기화 전 상태(각 서브모듈의 git dir 존재 여부, 공용 `.git/config` 의 `submodule.<n>.url|active`)를 **인덱스 배열**(bash 3.2 호환)로 기록한다. 초기화 안 된 경로에 파일이 이미 있으면 **아무것도 바꾸기 전에** exit 3 으로 거부한다(R4) — 롤백이 "이번 시도가 만든 것"만 지울 수 있다는 전제를 실제로 성립시키는 올바른 순서다. 실패 시 deinit → `.git` 파일 제거 → `modules/<name>` 제거 → 없던 `modules/` 제거 → config 복원 순으로 되돌리고, 마지막에 `git status` 로 워크트리가 여전히 쓸 수 있는 체크아웃인지 확인해 메시지를 갈라 준다. 사람이 강제 제거로 더 망가뜨리지 말라는 안내까지 있다.
|
||||
|
||||
### 1-4. `--force` 가 작업을 잃을 수 있는가 — 잃지 않는다
|
||||
|
||||
`worktree_remove.sh` 의 판정은 **변경을 가하기 전에** 모두 끝난다(`:57-121`): 잠금, 실행 중 세션(`--stop` 없으면 거부), 열린 잡, 트리 청결(서브모듈 포함), 서브모듈 소견. `--force` 는 워크트리 git dir 에 `modules/` 가 있어 git 이 plain remove 를 거부하는 경우에만, 그것도 **단 한 번** 쓴다(`METHOD="single --force"`).
|
||||
|
||||
- **R1(정지 중 쓰기)**: `judge_tree()` 를 `--stop` **이후 다시** 실행하고, 달라졌으면 `:154` 에서 거부한다.
|
||||
- **R6(tag/notes 로만 보존된 커밋)**: `lib_py/worktree.py:UNPUSHED_REVS = ("HEAD","--exclude=refs/stash","--all","--not","--remotes")` 로 모든 로컬 ref 기준 미푸시 커밋을 본다. stash 는 별도 확인.
|
||||
- **R5(deinit 저장소)**: `submodule foreach` 로 닿지 않는 `modules/` 아래 저장소까지 `--git-dir` 로 직접 검사하고, 판단 불가한 "부분 저장소"는 오류로 남겨 거부한다.
|
||||
|
||||
즉 `--force` 시점에는 "미커밋·미푸시·stash 없음"이 증명돼 있다. 한 가지만 적어 둔다(N8): plain/force 어느 쪽이든 git 은 **무시된 파일**을 말없이 지운다. 워크트리에 테스트가 남긴 `.mam/delegate_job_logs` 같은 산출물은 제거와 함께 사라진다(제어 평면이 본 체크아웃이므로 설계상 무해).
|
||||
|
||||
또 하나(N7): 열린 잡 검사는 `registry_dirs`(`MAM_JOBS_DIR` → `WORKSPACE_ROOT/.mam/jobs` → 행들의 `control_plane.mam_jobs_dir`)만 본다. 행이 없는 워크트리 로컬 `.mam/jobs` 의 잡은 보이지 않는다. prepare 가 만든 creator 는 제어 루트에 묶이므로 모델상 발생하지 않는 경로지만, 수작업으로 만든 워크트리에는 해당한다.
|
||||
|
||||
## 2. 항목 4 — resume 계약 변경(`2679ddf`)은 기존 사용자를 깨지 않는다
|
||||
|
||||
`resume_session.sh:50-70` 이 행의 `pane.cwd` 와 `--workspace` 를 비교해 다르면 exit 2, 경로가 사라졌으면 exit 2 로 안내한다. 기존 단일 워크스페이스 사용자에게 영향이 없는 이유:
|
||||
|
||||
1. `--workspace` 는 원래부터 **필수 인자**이고(`:42`), 기존 행의 `pane.cwd` 는 그 워크스페이스 경로다. 본 체크아웃의 실제 `.mam/agent-sessions.yaml` 5개 행 모두 `cwd: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux` 로, 평소 호출과 일치한다.
|
||||
2. 비교는 `mam_abs_workspace`(`lib.sh:1606-1609`, `cd -P … pwd -P`)로 정규화한 뒤 하므로 심링크·상대경로 표기 차이는 통과한다.
|
||||
3. `pane.cwd` 가 없는 구(舊) 행은 `ROW_CWD` 가 비어 검사 자체를 건너뛴다.
|
||||
|
||||
바인딩 영속은 **이미 실행 중인 경로에도** 적용된다: `:145` 가 `CONTROL_PLANE_JSON="$CP_JSON"` 를 `update_yaml_resumed.sh` 에 넘기고, 그쪽 `:168` 이 이를 읽어 행에 기록한다(A13). 계산 실패 시 워크트리면 exit 2, 제어 루트면 경고 후 무기록 — create 쪽(`create_session.sh:211-221`)과 같은 규칙이다.
|
||||
|
||||
## 3. 항목 5 — 리뷰 identity
|
||||
|
||||
- **기록**: `_record_round`(`runner.py`)가 라운드마다 `iteration/base_sha/head_sha/tree_sha/review_commit/isolation/review_path/jobs/verdicts/reviewers(evidence)/stale` 를 남기고 체크포인트에 `loop_id` 와 `review_rounds` 를 싣는다. 승격 시에는 리포트 옆에 `report-<jid>.identity.json` 을 원자적으로(`.tmp` → `replace`) 쓴다.
|
||||
- **스냅샷**: `_worktree_tree()` 가 임시 `GIT_INDEX_FILE` 로 `read-tree HEAD` → `add -A` → `write-tree` 하여 **실제 인덱스·status·ref 를 건드리지 않는다**. 미커밋이 있으면 `commit-tree` 로 ref 없는 스냅샷 커밋을 만든다. untracked 중첩 저장소는 gitlink 만 남아 고정 불가이므로 거부하고(`_snapshot`), dirty 서브모듈도 거부한다(`_dirty_submodules` 는 `--ignore-submodules=none` 으로 물어 `submodule.*.ignore` 설정이 숨기지 못하게 한다). 판정 불가(`None`)는 전부 실패로 떨어지는 fail-closed 다.
|
||||
- **격리**: `_open_review_tree` 는 `TMPDIR/mam-review/<loop_id>/<n>` 에 detached 워크트리를 만들고, **실패하면 격리를 낮추지 않고 exit 1**(로그 문구까지 그렇게 적혀 있다). `_close_review_tree` 는 remove 실패 시에만 `prune` 을 부르고, 부모 디렉터리는 비었을 때만 지운다. `run()` 이 `_run_body()` 를 `finally: _close_all_review_trees()` 로 감싸고 `release_guard` 도 같은 정리를 한다.
|
||||
- **stale 재리뷰**: 라운드 판정 뒤 `_round_is_stale`(현재 head·tree 비교)로 소스 변경을 잡아 승인 전량 폐기 후 재리뷰, 마지막 라운드면 exit 1. 승격 직전에도 `_identity_now() != (accepted head, tree)` 면 "source changed after approval; not promoting" 으로 exit 1(B10).
|
||||
- **원격 리뷰어**: `_check_remote_recipients` 가 **어떤 잡도 위임하기 전에** 판정 수신자 전원을 검사해 기본 거부, `--remote-review diff-only` 만 허용하며 그때 diff 가 truncated/unavailable 이면 증거가 없다고 exit 1.
|
||||
- **기본 동작 동일성**: `_context_active()` 가 거짓이면 `_worktree_context()` 는 빈 문자열을 반환하고 `collect_changes_diff(None)` 은 기존 호출과 동일하다. `_dispatch` 의 새 인자는 기본값(`remote=False, purpose="work"`)이라 프롬프트는 바이트 동일하다. 다만 옵션과 무관하게 `Loop id:` 로그 1줄과 체크포인트의 `loop_id` 필드가 늘었다(N9, 무해).
|
||||
|
||||
### N1 (비차단, 후속 권고) — 스냅샷 커밋이 gc 로 사라진다
|
||||
|
||||
`_snapshot` 의 스냅샷 커밋은 ref 가 없고, 유일한 도달 경로였던 리뷰 워크트리 HEAD 는 `_close_review_tree` 가 라운드 판정 직후 없앤다. 그래서 `identity.json` 의 `review_commit`·`tree_sha` 는 **나중에 검증하려 할 때 이미 없을 수 있다**. scratchpad 임시 저장소에서 재현했다(워크트리 제거 → `reflog expire --expire=now --all` → `gc --prune=now` → `git cat-file -e <snap>` 실패). 일상적으로도 unreachable 객체는 기본 2주 뒤 대상이다. 미커밋이 있던 라운드에만 해당하며(클린 트리면 `review_commit == head_sha`), 판정 자체의 정합성에는 영향이 없다.
|
||||
|
||||
→ 권고: `refs/mam/review/<loop_id>/<n>` 같은 ref 로 스냅샷을 고정하고, 루프 종료 시 정책적으로 정리(또는 보존).
|
||||
|
||||
### N6 (비차단) — 강제 종료 시 리뷰 워크트리 잔존
|
||||
|
||||
`finally`/`release_guard`/시그널 핸들러로 정상·TERM·INT 경로는 덮이지만, SIGKILL 이면 `TMPDIR/mam-review/...` 디렉터리와 소스 저장소의 worktree 항목이 남는다. `prune` 은 remove 실패 시에만 부른다(의도된 선택, 주석에 근거 있음).
|
||||
|
||||
## 4. #20 격리 — 설계와 실측
|
||||
|
||||
- `tests/conftest.py` 의 `pytest_configure` 가 **수집 이전에** ambient 바인딩 4개를 기록하고 `AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·`DELEGATE_JOB_LOGS_DIR` 를 제거한다. `WORKSPACE_ROOT` 는 **다른 체크아웃을 가리킬 때만** 제거한다(이 체크아웃을 가리키면 lib.sh 가 어차피 유도하는 값). import 시점에 `os.environ` 을 복사하는 모듈(`test_orc_onboard.SCRUBBED_ENV`) 때문에 세션 fixture 로는 늦다는 설명이 정확하다. `pytest_unconfigure` 가 되돌려 in-process `pytest.main()` 호출자를 배려한다.
|
||||
- autouse `_isolate_mam_state_dirs` 는 **env 만** 설정하고 디렉터리를 만들지 않으며, 숨김 이름(`.mam-test-state`)이라 `tmp_path` 를 나열하는 기존 테스트를 깨지 않는다. `mam_sandbox` 와 테스트 자신의 `setenv/delenv` 가 뒤에 실행돼 이긴다.
|
||||
- 세션 가드 `mam_state_leak_guard` + `_mam_state_guard.py` 는 **보고만 하고 절대 지우지 않는다**(실제 제어 평면이므로 옳다). 감시 대상은 이 체크아웃의 `.mam` 과 기록된 바인딩이 가리킨 제어 평면이며, 동시에 도는 실제 위임과는 "레코드 먼저 → 등록된 세션" 규칙으로 구분한다.
|
||||
- #15 옵트아웃(N4, 비차단): `test_issue15_registry_isolation.py` 의 누수 단언이 "전체 목록 동일"에서 "자기 잡 id 가 실제 루트에 없음 + 그 사이 늘어난 항목 중 자기 세션 이름이 없음"으로 **약해졌다**. 동시 실행되는 실제 위임 때문에 전체 비교가 오탐을 냈다는 근거가 타당하고, 세션 이름 스캔이 핵심 보장을 유지하므로 수용한다.
|
||||
|
||||
## 5. acceptance5 안정화(`7bc3e1a`·`371099a`·`e6d2168`) — 계약을 약화하지 않는가
|
||||
|
||||
| 변경 | 판단 |
|
||||
|---|---|
|
||||
| 세션 이름·HMAC secret 을 실행마다 유니크하게(`tag = os.urandom(4).hex()`) | **개선**. 공개 브로커에 retain 된 과거 dispatch 가 이번 실행의 잡 대신 배달되던 교차 오염을 없앤다 |
|
||||
| `--heartbeat-misses 3 → 10` | 허용. 이 시험의 목적은 "원격 러너가 브로커만으로 잡을 끝낸다"이고, K×N 무응답 임계 자체는 AC1·AC3 가 고정한다. 원격 러너의 `started --heartbeat` 계약은 그대로다 |
|
||||
| 단언에 `advanced to 'completed'` 추가 | 허용. retain 된 종단 이벤트만 배달된 경우도 프로토콜상 정당한 배달 확인이다 |
|
||||
| `_skip_if_broker_did_not_deliver` | 조건부 허용(N5). skip 은 "러너가 started 를 발행했고, 구독자가 **맞는 토픽**에 SUBSCRIBED 했고, 그 잡에 대해 **아무것도**(드롭 로그조차) 받지 못했다"는 세 조건이 동시에 성립할 때만이다. HMAC·identity 드롭, 잘못된 토픽, 발행 실패는 여전히 실패한다. 다만 구독자 배달 경로의 실제 회귀가 같은 증상을 내면 skip 으로 가려질 수 있다 |
|
||||
|
||||
## 6. 수용 사례·신규 테스트의 결정성·격리
|
||||
|
||||
- `tests/test_issue21_acceptance.py`(A1–A8, 278줄): 실제 herdr·실제 브로커를 쓰지 않는다. mock herdr / stub 위임 + `mam_sandbox`·`tmp_path` 기반이며, 브로커 관련 언급은 주석과 "브로커 도달 불가 조건에서 래퍼를 돌린다"는 A3 한 곳(`:102`)뿐이다.
|
||||
- `tests/test_issue21_loop_b.py`(526줄, L1–L21): 러너를 직접 구동하되 위임·대기를 스텁으로 바꾸고 임시 git 저장소에서 돈다. 브로커 의존 0, `time.sleep` 없음. L1/L12/L14b/L18b 가 **옵션 없는 기존 동작 불변**을 별도로 고정하는 점이 좋다.
|
||||
- `tests/test_issue21_phase2_loop_a.py`(649줄): `time.sleep(0.2)` 1회(`:312`) 외에 시간 의존 없음. `deploy/install.sh` 를 임시 타깃에 실행하는 경로가 있으나 모두 `tmp_path` 안이다.
|
||||
- `tests/test_issue20_state_isolation.py`(202줄): 순수 함수 단위 + 중첩 pytest 1건(설계된 skip).
|
||||
- uuid 사용은 `uuid.uuid4()`(issue15 의 `_unique`)로 브리프 규칙과 맞는다.
|
||||
|
||||
## 7. 문서 정합성과 권고의 타당성
|
||||
|
||||
- `docs/design/worktree-creator-model.md`: 항목 표가 1·2·3·6 = 완료(v4.6.0), 4·5·7 = 이 브랜치, 8 = 보류로 갱신됐고, 실행 예시가 `worktree_prepare.sh`/`worktree_remove.sh` 기준으로 다시 쓰였다. 호환성 표에 resume 의 exit 2 조건, remote 리뷰어 거부/opt-in, "실제 herdr pane 값 확인은 단계 C" 라는 한계가 명시돼 과장 없이 정확하다.
|
||||
- `multi-agent-mux-loop/SKILL.md` 가 `--review-isolation`·`--remote-review` 를 표에 추가하고 기본값·거부 시점(위임 이전)·evidence 기록까지 적는다. create/resume/stop SKILL.md 도 헬퍼를 반영한다.
|
||||
- PR 본문의 **비차단 권고 4건**을 코드에서 확인했고 모두 사실이다: ① A4 의 `_main_state` 비교가 untracked `.agents/`·`.mam/` 변화를 보지 못함, ② A4 가 `info/exclude` 추가를 **선반영**한 뒤 비교함(`test_issue21_acceptance.py:121-122`), ③ `_close_review_tree` 의 remove 실패 시 `prune` 이 전역, ④ `worktree_remove.sh` 의 서브모듈 거부 출력 중복. 넷 다 병합을 막을 성질이 아니며 후속으로 적절하다.
|
||||
- #30(멈춘 에이전트 감지) 등록은 타당하다. #18 이 "프로세스 생존"에 결속된 설계라 "살아 있지만 멈춘" 상태는 그 계약 밖이며, 해결은 알림·운영 수준이므로 별도 이슈가 맞다.
|
||||
|
||||
## 8. 전체 스위트 실행 (이 리뷰어가 직접, 워크트리에서)
|
||||
|
||||
```
|
||||
cd <pr-29 워크트리> && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfE tests/
|
||||
→ 2 failed, 1011 passed, 2 skipped in 2266.28s (0:37:46) (HEAD 46ea4cc, 실행 전후 동일)
|
||||
```
|
||||
|
||||
| 실패 | 판정 |
|
||||
|---|---|
|
||||
| `test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock` | **PR 회귀 아님**. 이 시험은 PR 이 건드리지 않았다(`git diff -U0` 의 issue14 hunk 는 전부 `_job_for_inprocess_publish` 뒤쪽 `:520` 이후). 단독 재실행에서도 close_delay 28.5 s(임계 20+8)로 재현되며, **다른 브랜치에 이미 `90a289d test(issue-14): widen the wall-clock close margin under suite load` 가 있다** — 임계 자체가 좁다는 기지 문제다 |
|
||||
| `test_issue19_subscriber_daemon_lifecycle.py::test_mqtt_loop_sigterm_exits_cleanly` | **브로커 flake**. 파일 자체가 PR 변경 0줄이고, 로그가 `broker did not accept connection; falling back to polling` 이다. 단독 재실행 **통과** |
|
||||
|
||||
- PR 본문의 "이 호스트 기준 실패 33건(#28)" 주장은 **내 실행으로는 확인되지 않는다**(내 조건에서는 2건). 제어 평면 바인딩이 실린 creator pane 조건에서 재현된 수치로 보이며, 내가 재현할 수 있는 조건이 아니므로 그 수치의 참·거짓을 주장하지 않는다. 내가 말할 수 있는 것은 **내 조건에서 새 회귀가 0 이라는 것**이다.
|
||||
|
||||
### 격리·위생 실측 (#20, 브리프 항목 4)
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs`(본 체크아웃) 전후 | **차이 0** (레코드 추가 0건) |
|
||||
| 실제 `.mam/delegate_job_logs` 전후 | **차이 0** |
|
||||
| 워크트리 `git status --short --ignored` 전후 | **완전 동일**(무시 파일 포함) — 스위트가 워크트리에 아무것도 남기지 않았다 |
|
||||
| 워크트리 HEAD | 실행 전후 `46ea4cc` |
|
||||
| 본 체크아웃 | `main @ 1134d74`, 클린 |
|
||||
|
||||
이전 회차(PR #22)에서 내가 지적했던 "리뷰어 pytest 가 워크트리에 `.mam/delegate_job_logs`·`.mam/shim` 을 남긴다" 가 이번 실행에서는 **재현되지 않았다**. #20 수정이 의도대로 동작한다.
|
||||
|
||||
## 9. 병합 차단 여부 (브리프 항목 8)
|
||||
|
||||
알려진 버전 bump 미포함(오케스트레이터가 v4.7.0 으로 처리) 외에 **병합을 막을 결함은 찾지 못했다**. N1–N9 는 모두 비차단이며, 후속 순위는 N1 > N2 > N5 순으로 본다.
|
||||
|
||||
| # | 지적 | 성격 |
|
||||
|---|---|---|
|
||||
| N1 | 스냅샷 커밋·tree 가 ref 없이 남아 gc 로 소멸 → `identity.json` 사후 검증 불가(재현 확인) | 후속 이슈 권고 |
|
||||
| N2 | prepare 의 `.venv` 는 여전히 심링크(실제 venv/인터프리터 경로 아님); 커밋 경로는 3중으로 닫혔으나 `rm -rf .venv/`·의존성 공유 위험 잔존 | 문서화 또는 옵션화 권고 |
|
||||
| N3 | prepare 가 공유 `info/exclude` 를 바꿔 본 체크아웃에도 적용(되돌림 경로 없음) | 기지·문서화됨 |
|
||||
| N4 | issue15 누수 단언이 전체 목록 비교에서 자기 잡 id + 세션 스캔으로 약화(근거 타당) | 수용 |
|
||||
| N5 | acceptance5 의 skip 경로가 구독자 배달 회귀를 가릴 여지(조건은 매우 좁음), `--heartbeat-misses 3→10` | 수용 |
|
||||
| N6 | SIGKILL 시 리뷰 워크트리·worktree 항목 잔존(prune 은 실패 시에만) | 사소 |
|
||||
| N7 | remove 의 열린 잡 검사가 행 없는 워크트리 로컬 `.mam/jobs` 를 못 봄 | 모델 밖 경로 |
|
||||
| N8 | remove 는 plain/force 모두 무시 파일을 말없이 삭제(추적 작업 손실은 없음) | 정보 |
|
||||
| N9 | 옵션 없는 루프에도 `Loop id:` 로그 1줄과 체크포인트 `loop_id` 필드가 추가(프롬프트는 바이트 동일) | 정보 |
|
||||
|
||||
## 10. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
WT=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29
|
||||
(cd "$WT" && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfE tests/) # 2 failed / 1011 passed
|
||||
(cd "$WT" && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q \
|
||||
tests/test_issue14_delivery_ack.py::test_wrapper_idle_grace_never_outlives_the_wall_clock \
|
||||
tests/test_issue19_subscriber_daemon_lifecycle.py::test_mqtt_loop_sigterm_exits_cleanly) # issue19 통과, issue14 재현
|
||||
git -C "$WT" diff 1134d74...HEAD -U0 -- tests/test_issue14_delivery_ack.py | grep '^@@' # 실패 시험은 변경 밖
|
||||
# N1 재현 (임시 저장소에서만)
|
||||
R=$(mktemp -d); cd "$R"; git init -q .; git config user.email t@t; git config user.name t
|
||||
echo a > a; git add a; git commit -qm base; H=$(git rev-parse HEAD); echo dirty > b
|
||||
GIT_INDEX_FILE=$R/idx git read-tree HEAD; GIT_INDEX_FILE=$R/idx git add -A; T=$(GIT_INDEX_FILE=$R/idx git write-tree)
|
||||
C=$(git commit-tree "$T" -p "$H" -m snap); git worktree add -q --detach "$R/wt" "$C"; git worktree remove --force "$R/wt"
|
||||
git reflog expire --expire=now --all; git gc --prune=now -q; git cat-file -e "$C^{commit}" || echo "snapshot gone"
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260921T150212Z-97d845",
|
||||
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
|
||||
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
|
||||
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
|
||||
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "293e905f",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-21T15:25:44Z"
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
# 🔍 Review Report — PR #32 병합 전 독립 리뷰 (job `293e905f`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-22
|
||||
- **대상**: PR #32 `fix/issue-25` **@ `1e82651`**, base `main 6fc9194` (v4.7.0). 5커밋, 4파일 `+486/−31`.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260921T150212Z-97d845/1` (브리프가 지정한 리뷰 워크트리). 실제로 거기서만 검증했다.
|
||||
- **identity 확인**: 검증 경로의 `HEAD = 1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd`, `HEAD^{tree} = 4fd82ad1c2682d12c3f5114818f97291af28a032` — 브리프의 `head`·`tree` 와 **일치**한다(항목 5 identity 가 실제로 작동함을 이 잡 자체가 보여준다).
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-32`)·본 체크아웃(`main 6fc9194`) 모두 수정·커밋·checkout 없음. 실험은 전부 scratchpad 임시 디렉터리(uuid4 이름)에서 했고, pytest 는 `--basetemp` 을 검증 경로 밖으로 주었다. 실제 `.mam`·데몬·세션 조작 없음, push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**병합을 막을 결함은 없다.** 핵심 변경(암묵적 계획서 로드 제거, `--plan-file` 명시 재사용, 네 지점 공통 계획 텍스트 검사)은 코드·문서·테스트가 일치하며, 새 테스트 17건 중 **13건이 수정 전 소스에서는 실패**한다(직접 확인). 비차단 지적 3건(E1–E3)을 남긴다.
|
||||
|
||||
## 1. 암묵적 로드 제거 (항목 1)
|
||||
|
||||
| 확인 | 결과 |
|
||||
|---|---|
|
||||
| Python 러너 | `runner.py` 에서 `glob` import 가 사라졌고, 남은 `glob` 사용처는 `find_report` 의 `report-final.md` 탐색(`:568`) 하나뿐이다. `.agents/reports/<planner>/plan-*.md` 를 읽는 코드가 없다 |
|
||||
| 레거시 셸 러너 | `run_loop.sh` 의 `EXISTING_PLAN_FILE`/`ls -t .../plan-*.md` 블록이 통째로 사라지고 `log_info "No plan loaded; the Creator plans by itself."` 만 남았다 |
|
||||
| 다른 경로 | 저장소 전체에서 `plan-*.md` 를 읽는 코드는 없다(승격 쪽 **쓰기**만 `runner.py:1368-1372` 에 남는다 — 정상) |
|
||||
| planner 자동 선택 | `resolve_planner_session`(`run_loop.sh:386`)·`resolve_planner_session` 파이썬 대응은 남아 있으나, 그 결과는 **plan 모드의 위임 대상과 승격 경로**에만 쓰인다. 자기계획 모드에서 planner 이름으로 파일을 찾는 코드는 없다 |
|
||||
|
||||
## 2. `--plan-file` (항목 2) — 실측
|
||||
|
||||
검증 경로의 러너를 임시 제어 루트(`WORKSPACE_ROOT=<tmp>/ctl`)로 직접 돌려 확인했다.
|
||||
|
||||
| 입력 | 결과 |
|
||||
|---|---|
|
||||
| 없는 파일 | `[✗] --plan-file <절대경로>: not a readable file with plan text in it.` · **exit 1** |
|
||||
| 빈 파일 / 공백만 있는 파일 | 같은 메시지 · exit 1 |
|
||||
| 디렉터리 | 같은 메시지 · exit 1 (`is_file()` 로 걸린다) |
|
||||
| 읽기 권한 없음(`chmod 000`) | 같은 메시지 · exit 1 (`OSError` → `None`) |
|
||||
| `--plan` 과 함께 | `ERROR: --plan-file cannot be combined with --plan.` + 사용 예 2줄 · exit 1 |
|
||||
| 상대경로 | **제어 루트 기준**. 호출 cwd(`<tmp>/elsewhere`)에 `plans/p.md` 가 실제로 있어도 러너는 `<ctl>/plans/p.md` 를 찾고 실패한다 — #25 의 "다른 디렉터리의 동명 파일을 집어오는" 사고를 구조적으로 막는다 |
|
||||
| 공백 있는 경로(`my plans/p.md`, 상대) | 정상 로드: `Using the plan given with --plan-file: <ctl>/my plans/p.md (modified ... UTC)` (셸을 거치지 않으므로 인용 문제 없음) |
|
||||
| 절대경로·`~` | `Path.__truediv__` 가 절대경로를 그대로 두고 `expanduser()` 가 앞서 적용된다(P3d 가 고정) |
|
||||
| `--review-only` 와 함께 | 경고 후 무시(`log_warn`), `current_plan` 을 채우지 않는다 |
|
||||
| 레거시 셸(`MAM_LOOP_PY=0`) | `ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review/--plan-file require the Python loop (MAM_LOOP_PY=1)` · exit 1 — **동등 구현 대신 명시적 거부**이며, 기본 경로(`MAM_LOOP_PY=1`)는 `exec "$_py" -m lib_py.loop "${MAM_LOOP_ARGV[@]}"`(`run_loop.sh:262`)로 원본 argv 를 그대로 넘기므로 공백 경로도 안전하다 |
|
||||
|
||||
읽기가 **잡을 하나도 만들기 전**(parse_args 단계)에 끝나는 것도 확인했다 — 실패 시 레지스트리에 아무 흔적이 없다.
|
||||
|
||||
## 3. 계획 텍스트 검사 (항목 3)
|
||||
|
||||
`_plan_text()`(`runner.py:303-317`)가 "읽을 수 있는 일반 파일 + 공백 제외 내용 있음"을 단일 판정으로 만들고, **파이썬 러너의 네 지점이 모두 이를 쓴다**: `--plan-file`(`:265`), `--plan` 초기 계획서(`:1025`), 논의 턴 refine(`:1054`), 리뷰 에스컬레이션 refine(`:1284`).
|
||||
|
||||
- 빈 초기 계획서 → `log_error` 후 **return 1**. 이때 이미 만든 잡은 `release_guard`/정리 경로가 담당하고, 자기계획으로의 조용한 격하가 사라졌다.
|
||||
- 빈 refine → 경고 + **이전 계획 유지**. 그래서 "갱신된 다음 계획서에 입각하여…" 프롬프트가 빈 계획을 싣는 일이 없다.
|
||||
- 에스컬레이션 경로(`[ESCALATE: PLANNER]`)는 `if self.plan_mode and complex_fix`(`:1275`)로 **plan 모드에만** 걸린다. `--plan-file` 로 계획을 실은 실행에서 ESCALATE 판정이 나오면 재계획 없이 일반 수정 라운드로 간다 — 샌드박스에서 직접 구동해 확인했다(Planner 위임 0건, 수정 프롬프트는 "리뷰어들이 지적한 다음 피드백…"). SKILL.md:31 의 "수정 라운드에는 리뷰 피드백만" 서술과 **일치**한다. 다만 이 동작 자체는 E2 로 남긴다.
|
||||
|
||||
## 4. 체크포인트 (항목 4)
|
||||
|
||||
`.mam/loop-checkpoint.json` 의 `current_plan` 은 **쓰기 전용**이다. `checkpoint.load_checkpoint()` 는 정의되어 있으나 `runner.py` 어디에서도 호출하지 않는다(`grep 'checkpoint\.'` → `save_checkpoint`·`remove_checkpoint` 뿐). 따라서 본 체크아웃 체크포인트에 남아 있는 Issue #6 계획 텍스트가 다음 루프나 재개에서 되살아날 경로는 없다. 계획은 매 실행마다 `--plan-file` 또는 `--plan` 으로만 들어온다.
|
||||
|
||||
## 5. 옵션 없을 때의 동작 변화 (항목 5)
|
||||
|
||||
- 자기계획 프롬프트(`runner.py:1095`)와 승격 경로(`:1367` `if self.plan_mode and self.current_plan and self.plan_job_id`)는 그대로다. 즉 `--plan-file` 로 실은 계획서는 **승격되지 않는다**(승격은 plan 모드의 planner 산출물만) — 옳은 구분이다.
|
||||
- 바뀐 것은 "planner 리포트 디렉터리에서 최신 plan-*.md 를 끌어오던" 동작 하나뿐이다. 그 외 프롬프트 문구·옵션 기본값·종료 코드는 동일하다.
|
||||
- 기존 스위트 정합은 §6 에서 실측했다.
|
||||
|
||||
## 6. 테스트 (항목 6)
|
||||
|
||||
### 6-1. 검증 경로에서의 실행
|
||||
|
||||
```
|
||||
cd <검증 경로> && PYTHONDONTWRITEBYTECODE=1 <제어 루트 .venv/bin/python> -m pytest -p no:cacheprovider -q -rfEs \
|
||||
--basetemp=<검증 경로 밖> tests/test_issue25_plan_reuse.py tests/test_loop_cli.py tests/test_issue5_phase3_loop.py \
|
||||
tests/test_issue21_loop_b.py tests/test_issue21_phase2_loop_a.py tests/test_issue21_acceptance.py
|
||||
→ 2 failed, 108 passed in 220.98s (skipped 0)
|
||||
```
|
||||
|
||||
**실패 2건은 PR 회귀가 아니라 리뷰 워크트리에 `.venv` 가 없어서 생긴 환경 문제다.**
|
||||
|
||||
| 실패 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `test_loop_cli.py::test_creator_parses_and_reaches_session_check` | `run_loop.sh` 를 하위 프로세스로 띄우는데, `REPO_ROOT`(= 검증 경로)에 `.venv` 가 없어 `_py` 가 시스템 python 으로 떨어지고 `RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'` 로 죽는다(`run_loop.sh:253`) | `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 만 주고 둘을 다시 돌리면 **2 passed in 0.34s** |
|
||||
| `test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default` | 동일 | 동일 |
|
||||
|
||||
두 시험 모두 PR 이 건드리지 않은 파일이며, 실패 메시지에 계획서·`--plan-file` 은 등장하지 않는다.
|
||||
|
||||
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본을 만들고 `runner.py`·`run_loop.sh`·`SKILL.md` **세 파일만 base `6fc9194` 판으로 되돌려** 새 테스트를 돌렸다(검증 경로 자체는 건드리지 않았다).
|
||||
|
||||
```
|
||||
13 failed, 4 passed in 1.51s
|
||||
FAILED P1, P2, P3b, P3c, P3d, P5, P9[empty], P9[whitespace], P10, P11, P7, P7b, P8
|
||||
```
|
||||
|
||||
통과한 4건(P3·P4·P5b·P6)은 옵션 없는 일반 CLI 오류·plan 모드 정상 경로라 수정과 무관한 것들이다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
|
||||
|
||||
### 6-3. 격리
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs` 전후 | **차이 0** |
|
||||
| 실제 `.agents/reports` | 새 항목 없음(테스트는 각자 `tmp_path` 안에 제어 루트를 만든다 — `ctl` 픽스처) |
|
||||
| 검증 경로 tracked 파일 | 변화 없음(`git status --short` 클린, HEAD `1e82651` 유지) |
|
||||
| 검증 경로 ignored | 내 실행이 `.mam/`(무시됨)을 만들었다. tracked 파일·커밋에는 영향이 없으며, 지우면 리뷰 트리를 수정하는 셈이라 그대로 두었다 → E3 |
|
||||
| 실행 시간 | 새 파일 `test_issue25_plan_reuse.py` 는 전체 17건이 **1.5 s** 안에 끝난다(브로커·herdr 의존 없음, 모두 stub) |
|
||||
|
||||
## 7. 문서 (항목 7)
|
||||
|
||||
- 옛 서술("기존에 승격된 계획서가 있다면 이를 로드")은 `.agents`·`docs` 어디에도 남아 있지 않다(전수 grep).
|
||||
- SKILL.md 의 사용법 문자열·CLI 표·Workflow 예시·Mermaid 다이어그램이 모두 `--plan-file` 기준으로 맞춰졌고, "계획서가 비어 있을 때" 절이 네 지점의 판정을 정확히 설명한다.
|
||||
- 러너 usage 와 셸 usage 문자열이 동일하다(P7 이 이를 고정한다).
|
||||
- 배포 사본: 저장소의 `.claude/skills/*` 는 `../../.agents/skills/*` **심링크**라 자동으로 동기화된다. 사용자 홈 `~/.claude/skills` 에는 mux 스킬 사본이 없다. 옛 문구를 가진 사본은 발견되지 않았다.
|
||||
|
||||
## 8. 병합 차단 여부 (항목 8)
|
||||
|
||||
버전 락스텝 bump 부재(`lib.sh:32`·`eager.py:16`·loop SKILL.md 모두 `4.7.0`, 오케스트레이터가 4.7.1 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||
|
||||
| # | 비차단 지적 | 근거 |
|
||||
|---|---|---|
|
||||
| E1 | 레거시 셸 러너(`MAM_LOOP_PY=0`)의 plan 모드 읽기에는 빈 계획서 검사가 없다: `CURRENT_PLAN=$(cat "$PLAN_FILE")`(`run_loop.sh:501`). D13/D14 계약은 파이썬 러너에만 성립하는데 SKILL.md 의 "계획서가 비어 있을 때" 절은 이를 명시하지 않는다 | 후속: 한 줄 거부 또는 문서에 "Python 러너 기준" 명시 |
|
||||
| E2 | `--plan-file` 로 계획을 실은 실행에서 `[ESCALATE: PLANNER]` 는 재계획 없이 일반 수정 라운드로 내려간다(`runner.py:1275` 가 `plan_mode` 로 게이트). 샌드박스 구동으로 확인(Planner 위임 0건). 이 게이트는 PR 이전부터 있던 것이고 SKILL.md 서술과도 모순되지 않지만, "계획서를 갖고 도는 루프인데 에스컬레이션이 무력화된다"는 점은 문서에 없다 | 문서 보강 권고 |
|
||||
| E3 | 리뷰 격리 워크트리에는 `.venv` 가 없어, `run_loop.sh` 를 하위 프로세스로 띄우는 스위트는 `AGENT_PYTHON_BIN` 없이는 반드시 실패한다(§6-1). 또한 그 워크트리에서 pytest 를 돌리면 무시되는 `.mam/` 이 생긴다. **PR #32 가 아니라 Issue #21 항목 5 운영 쪽 후속**이다 | 리뷰 브리프의 테스트 명령에 `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 를 포함하는 것을 권고 |
|
||||
| E4 | `runner.py:1070` 의 `os.path.getmtime(self.plan_file)` 는 파싱 시점과 Phase 1 사이에 파일이 사라지면 예외로 죽는다(메시지 없는 트레이스백). 극히 좁은 경합 | 사소 |
|
||||
|
||||
## 9. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD HEAD^{tree} # 1e8265... / 4fd82a... = 브리프의 head·tree
|
||||
# --plan-file 실패 경로 (임시 제어 루트에서만)
|
||||
C=$(mktemp -d); mkdir -p "$C/ctl" "$C/elsewhere/plans"; : > "$C/ctl/empty.md"; echo x > "$C/elsewhere/plans/p.md"
|
||||
(cd "$C/elsewhere" && PYTHONPATH="$RP/.agents/skills" WORKSPACE_ROOT="$C/ctl" MAM_STATE_JSON='{"herdr_sessions":[]}' \
|
||||
"$PY" -m lib_py.loop --creator c --task t --plan-file plans/p.md; echo "rc=$?") # ctl 기준 경로로 실패, rc 1
|
||||
# 수정 없이는 실패하는지 (사본에서만)
|
||||
S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam --exclude nats-docker "$RP/" "$S/"
|
||||
for f in .agents/skills/lib_py/loop/runner.py .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||
.agents/skills/multi-agent-mux-loop/SKILL.md; do git -C "$RP" show 6fc9194:"$f" > "$S/$f"; done
|
||||
(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt tests/test_issue25_plan_reuse.py) # 13 failed
|
||||
# 실패 2건이 환경 문제임을 확인
|
||||
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt2 \
|
||||
tests/test_loop_cli.py::test_creator_parses_and_reaches_session_check \
|
||||
tests/test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default) # 2 passed
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -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,100 @@
|
||||
# 리뷰 리포트 — Job 2f19fe7f (PR #46, fix/issue-42 단계 1·2)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261003T222707Z-a0de27 / base 45d1621 / head 8d26106 / tree b579380 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 로 일치 확인, 미커밋 변경 없음
|
||||
- 커밋: 7670b41(단계 1·2), e6a46cd(R1: stop exit 1 이 stop 뒤에도 남), 8d26106(R2: passthrough 코드는 표에 있어야 함)
|
||||
|
||||
## 결론 요약
|
||||
차단 결함 없음. 지운 (a) 블록 5개는 모두 진입 스크립트가 그대로 하는 일이고, 새 옵션·종료 코드 표와 "정정" 다섯 곳은 스크립트 코드 및 샌드박스 실행 결과와 일치한다. 제품 코드 변경 없음. 비차단 관찰 4건(아래 §6).
|
||||
|
||||
## 1. 분류의 정확성
|
||||
### resume Workflow (base resume SKILL.md:59-118) → resume_session.sh
|
||||
| 지운 단계 | 스크립트 위치 |
|
||||
|---|---|
|
||||
| 세션 ID 해석 (`resolve_session_id.sh --session`) | resume_session.sh:111-112 |
|
||||
| Class A fresh-spawn 폴백 / claude·grok exit 1 | :122-130 (spawn-spec :173-184) |
|
||||
| HERDR_SESSION_NAME·MAM_WS_LABEL 해석 | :132-144 |
|
||||
| herdr alive 분기 | :147-158 — **attach 하지 않고** `update_yaml_resumed.sh` 후 exit 0 |
|
||||
| CMD_FULL (에이전트별) | :185-197 (adapter resume-spec + 동일 표 폴백) |
|
||||
| 기동 | :237 `_herdr new-session` |
|
||||
| handle_startup_dialogs | :241 |
|
||||
| update_yaml_resumed.sh | :247-250 |
|
||||
| attach | **스크립트에 없음** |
|
||||
|
||||
attach 만 스크립트에 없는 단계(= (b))인데, 문서는 이를 지우지 않고 "It never attaches; attaching is a separate step: `herdr agent attach <name>`" 로 남겼다(resume SKILL.md Workflow 아래 문단, Case 3 정정). 따라서 절차 유실 없음. 문서에만 있던 `$CLAUDE_ID_FLAG`(정의되지 않은 변수)도 함께 사라졌다(인벤토리 mismatch R4) — 정정이 맞다.
|
||||
|
||||
### stop pre-flight (base stop:38-63) → stop_session.sh
|
||||
YAML 존재 :99(exit 1), 등록 확인 :127-143(exit 1), 이미 stopped no-op :184-188(exit 0). 문서에만 있던 `--force-no-yaml` 은 어떤 스크립트도 받지 않는다(:76-90 파서에 없음) — 삭제가 맞다.
|
||||
|
||||
### create pre-flight (base create:35-52) → create_session.sh
|
||||
워크스페이스 :111, has_real_herdr :114, CLI in PATH :115, 인증 :117-157(선택된 에이전트만). B-3 함정(command -v / type -P) 설명은 산문으로 보존.
|
||||
|
||||
### create Workflow (base create:168-220) → create_session.sh
|
||||
이미 존재 :166-168(**exit 3**, 문서는 1이었음), spawn-spec :207, spawn :252-276, TUI 대기 :361-398(exit 42), 롤백 trap :320-352(exit 43). `sleep 6` 하드코딩과 claude 무플래그 기동은 문서 쪽 오류였다.
|
||||
|
||||
### create YAML 수동 추가 (base create:226-252)
|
||||
행 기록은 create_session.sh:491-610 heredoc 이 한다. lib.sh shim 주석은 Output format 아래 산문으로 이동.
|
||||
|
||||
### (b) 로 남은 블록 5개
|
||||
create:217·resume:103·stop:127 (사후 확인), monitor:182 (에이전트 폴링 지침), status:30 (status.sh 에 has_real_herdr 검사가 없음 — status.sh:22 는 YAML 만). 각 스크립트를 grep 해 같은 명령을 제공하는 진입점이 없음을 확인했다. 3단계 입력으로 타당하다.
|
||||
|
||||
### 블록 수 대조
|
||||
base 의 펜스 블록을 테스트의 `parse_blocks` 로 세면 37개(create 11, delegate-job 1, loop 5, monitor 7, orc-onboard 2, resume 2, status 4, stop 5). 지운 (a) 5개의 base_start/base_sha 는 모두 base 와 일치한다. 현재 33개 = 37 − 5(a) − 1 + 2(새 Workflow 호출 블록). 이 "− 1"은 관찰 O1 참고.
|
||||
|
||||
## 2. 옵션·종료 코드 표 대조
|
||||
- create 옵션 표 ↔ 파서 create_session.sh:76-92: 12개 항목 모두 일치(별칭 `--name`, `--herdr-server` 포함; `--isolate` no-op :89-90; `--wrapper` 는 :257 에서 존재 여부와 무관하게 강제).
|
||||
- create 종료: 1 = :111-156 사전 검사 + :636·:659 (spawn 뒤 브리프/주입 실패, trap 롤백); 2 = :92-110 인자, :234 바인딩 거부, :245 공백; 3 = :168; 4 = heredoc `raise SystemExit(4)` :515 (중복 running 행, set -e 로 통과 → trap 롤백); 42 = :398; 43 = :348.
|
||||
- resume 옵션 표 ↔ :37-46 일치. 종료 0 = :150·:158(alive, attach 없음)·:227(dry-run)·정상 종료; 1 = :128·:204·:209 + update_yaml_resumed.sh:47; 2 = :46-56·:73·:77·:104·:221 + update_yaml_resumed.sh:42-46·:74.
|
||||
- stop 종료 0 = :188; 1 = :99·:142 + heredoc :310(R7); 2 = :86·:89·:95·:98·:121; 3 = :198; 4 = :281.
|
||||
- status `--json` 키: reconcile.sh:1081-1088 의 6개 최상위 키 + status.sh:213-232 의 sessions_detail 17개 키와 정확히 일치. resume_state 값 yes/MISSING/scan/no 도 status.sh 의 resume_on_disk 반환값과 일치.
|
||||
|
||||
샌드박스 실행(임시 HOME·WORKSPACE_ROOT·AGENT_SESSIONS_YAML, `env -i`, herdr 미호출 경로만):
|
||||
| 실행 | rc | 표 |
|
||||
|---|---|---|
|
||||
| stop `--purge-conversation` (no --yes) | 3 | 3 ✓ |
|
||||
| stop 미등록 세션 | 1 | 1 ✓ |
|
||||
| stop `--mode x` | 2 | 2 ✓ |
|
||||
| resume 원래 cwd 없음 `--dry-run` | 2 | 2 ✓ |
|
||||
| create 없는 워크스페이스 | 1 | 1 ✓ |
|
||||
| create `--agent foo` | 2 | 2 ✓ |
|
||||
샌드박스 YAML 은 변경되지 않았다.
|
||||
|
||||
## 3. 문서만으로 같은 작업이 가능한가
|
||||
- 세 진입점 모두 호출 한 줄에 필수 옵션이 표시되어 있고, 지운 절차는 전부 스크립트 안에 있으므로 호출 한 줄로 충분하다.
|
||||
- 사람이 판단할 지점은 보존·강화됐다: stop 예제가 `--purge-conversation --yes` 로 바뀌면서 "되돌릴 수 없다, 사용자가 확인한 뒤에만 `--yes`" 문장이 추가됐다(실제로 --yes 없으면 exit 3 이므로 이전 예제는 동작하지 않았다).
|
||||
- resume 은 spawn 뒤 실패 시 롤백하지 않는다는 점과 `multi-agent-mux-status` 로 먼저 확인하라는 복구 지침이 새로 들어갔다(resume_session.sh 에 trap 없음 — 사실과 일치). stop 표 아래 복구 문단도 같다.
|
||||
|
||||
## 4. 문서 가드 테스트 견고성
|
||||
스크래치 사본에서 변이 시험:
|
||||
| 변이 | 결과 |
|
||||
|---|---|
|
||||
| create `exit 43`→`exit 44` | 2 failed ✓ |
|
||||
| create `exit 42` 제거(표 행이 낡음) | 2 failed ✓ |
|
||||
| 주석 `# exit 9` 추가 | 통과 ✓ (주석 제외 규칙) |
|
||||
| 실제 `exit 9` 추가 | 2 failed ✓ |
|
||||
| stop 표의 3 행 삭제 | 2 failed ✓ |
|
||||
| stop 예제의 `--yes` 제거 | 1 failed ✓ |
|
||||
| base 의 create pre-flight 블록 되살림 | 1 failed ✓ |
|
||||
| stop SKILL.md 블록 위에 산문 한 줄 추가(정상 편집) | 1 failed (관찰 O2) |
|
||||
| `$(( 1 << 2 ))` 뒤 `exit 9` | 통과 ✗ (관찰 O3) |
|
||||
|
||||
추출 규칙: `(?<![\w.])exit\s+(\d+)` 은 `sys.exit(7)`(stop:139) 을 세지 않고, heredoc 본문은 `SystemExit(N)` 만 passthrough 로 모은다. `<<<` here-string 은 lookbehind 로 제외된다. 기존 가드 test_tier2_component.py::test_comp_docs_stop_examples_pass_agent(stop 예제 ≥3, 모두 --agent)는 아래 테스트 실행에서 통과한다.
|
||||
|
||||
## 5. 범위
|
||||
`git diff --stat 45d1621 8d26106`: SKILL.md 4개 + tests/data/skill_code_blocks.json + tests/test_issue42_skill_blocks.py. 스크립트·Python·버전 파일 변경 없음(버전 bump 없음).
|
||||
|
||||
## 6. 비차단 관찰
|
||||
- **O1 인벤토리 누락 1건**: base create SKILL.md:263-266 의 (c) 호출 블록(`create_session.sh … --session "$SESSION_NAME"`, sha ceb96a8c1293)이 지워졌지만 skill_code_blocks.json 의 blocks·removed 어디에도 없다. 내용은 새 Workflow 호출 블록이 대체하므로 정보 유실은 없지만, "37개를 분류했다"는 데이터는 실제로 36개만 다룬다. 가드는 현재 블록만 비교하므로 잡지 못한다. removed 에 class "c" 로 기록하거나 test_no_duplicate_block_left 의 `class == "a"` 를 완화하는 정리를 권한다.
|
||||
- **O2 줄 번호 결합**: 인벤토리가 start/end 줄 번호를 고정하므로 블록 위에 산문 한 줄만 넣어도 실패한다. 실패 메시지가 실제 튜플을 출력해 갱신은 기계적이지만, 3단계에서 산문 편집이 잦다면 sha+순서만 비교하거나 재생성 스크립트를 두는 편이 낫다.
|
||||
- **O3 산술 시프트 오인**: `_HEREDOC` 이 `$(( 1 << 2 ))` 를 heredoc 시작으로 읽어 이후 모든 줄을 건너뛴다. 현재 세 진입 스크립트에는 이 패턴이 없어(grep 확인) 실제 영향은 없다.
|
||||
- **O4 표 세부 문구**: (a) create exit 42 는 "프로세스가 사라짐" 외에 `MAM_KILL_ON_READY_TIMEOUT=1` 이면 살아 있어도 42 (create_session.sh:388-398). (b) stop "이미 stopped 면 no-op" 은 `--purge-conversation` 이 아닐 때만(:185). (c) resume 문서의 "lines 50-223" 은 사전 검사의 실제 범위(:50-221)와 대략 맞다. 모두 문서 정밀도 수준이다.
|
||||
|
||||
## 7. 항목 6 — purge 기준 실패
|
||||
이 호스트에서 검증 경로의 tests/test_issue10_13_purge.py: **23 passed**. 이 PR 은 제품 코드를 바꾸지 않으므로 Linux 의 12건(#28)과 무관하다. "알려진 기준 실패" 표현은 Linux 기준이라는 한정이 필요하다.
|
||||
|
||||
## 8. 테스트
|
||||
지정 6개 파일(test_issue42_skill_blocks, test_tier2_component, test_orc_onboard, test_loop_cli, test_o1_rebuttal, test_version_consistency): **129 passed / 0 failed / 1 skipped** (296 s, rc 0). skip 은 test_claude_skills_link_resolves_to_agents_skills(워크트리에 .claude/skills 없음 — 브리프상 정상). test_issue42_skill_blocks.py 단독: 28 passed / 1 skipped.
|
||||
|
||||
외부 브로커 미사용(MQTT_* unset, .mam.env 미export). 실제 .mam/jobs 항목 수 변화: +0 (이 잡 디렉터리 외 생성 없음).
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261001T230324Z-b511bf",
|
||||
"base_sha": "0051fd6c3b088f8b265928ccd94ea31053fdde7e",
|
||||
"head_sha": "75f364e4b47d8db95ce2a7e041b69b8e8b473e64",
|
||||
"tree_sha": "ec1fdc453ec6fdf2c09165c92a64d54e684f5a3a",
|
||||
"review_commit": "75f364e4b47d8db95ce2a7e041b69b8e8b473e64",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "31a04751",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-02T00:58:55Z"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# 🔍 Review Report — 후속 `fix/followups-41-43` 2회차 (job `31a04751`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-02
|
||||
- **대상**: `0051fd6..75f364e`(9커밋). 1회차(`95eabc6`, 내 판정 PASS — job `76ca3b1b`) 이후 델타는 `75f364e` 1커밋(3파일 `+41/−2`, `git diff --check` 클린).
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T230324Z-b511bf/2`. **identity 확인**: `HEAD = 75f364e4…3e64`, `HEAD^{tree} = ec1fdc45…5a3a` — 브리프와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 0051fd6`) 수정·커밋·checkout 없음. 되돌린 사본은 scratchpad 에서만. 실제 `.mam.env` 를 읽거나 출력·export 하지 않음. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**델타는 codex R1(실패한 env 로드가 "로드됨" 으로 캐시됨)을 정확히 닫고, 1회차 판정(F1–F6 완료, PASS)을 뒤집을 요소는 없다.** 1회차 내 리포트의 F1–F6 판단은 이 트리에도 그대로 유효하다 — 델타가 F2 의 `mqtt_common.py` 두 줄, MESSAGING.md 한 줄, 테스트 1건뿐이기 때문이다.
|
||||
|
||||
## 1. 75f364e — 실패한 로드를 캐시하지 않음 (`mqtt_common.py:128`)
|
||||
|
||||
- **결함(codex R1)**: F2 에서 `load_env_or_report()` 가 프로세스를 끝내지 않고 False 를 돌려주게 되자, `ensure_env_loaded()` 가 `_load_dotenv()` **이전에** `_env_loaded = True` 를 세우던 순서가 문제가 됐다. 한 프로세스 안에서 두 번째 `main()` 은 "이미 로드됨" 으로 보고 **설정 없이** 진행했다.
|
||||
- **수정**: 플래그를 `_load_dotenv()` **뒤로** 옮겨, 성공한 로드만 캐시한다. `EnvFileError` 가 나면 플래그는 거짓으로 남아 다음 호출이 파일을 다시 읽는다.
|
||||
- **수정 없이는 실패하는가 — 확인함**: 사본에서 `mqtt_common.py` 만 `95eabc6` 판으로 되돌리고 새 `test_a1_a_failed_load_is_not_cached_as_loaded` 를 돌리면 실패한다. 실패 내용이 결함 그대로다: `rc: [1, 0]`(두 번째 `registry.main` 이 rc 0), `registered: ['<job>.json']`(**설정 없이 잡을 등록**), `recovered: [True, None]`(고친 파일도 다시 읽지 않음). 새 코드에서는 `rc [1, 1]`·등록 0건·두 번 모두 한 줄 `ERROR`·고친 파일 읽힘·성공 뒤 캐시 유지(바꾼 파일을 다시 읽지 않음)를 모두 단언한다.
|
||||
- **부분 적용 여부**: `envfile.parse` 는 파일 전체를 먼저 읽고 디코딩한 뒤에만 값을 넣으므로, 실패한 로드가 환경을 절반만 바꾸는 일은 없다. `.mam.env` 와 옛 `.env` 가 함께 있으면 `_load_dotenv` 는 `.mam.env` 하나만 읽는다. 따라서 재시도 때 두 파일이 섞이는 경로도 없다.
|
||||
- **MESSAGING.md:369**: 문장 끝을 실제 동작(파일 없음 → 조용히 건너뜀, 있는데 못 읽음 → 진입점이 `ERROR` 한 줄 + rc 1)에 맞춘 문구 정리.
|
||||
|
||||
## 2. 테스트
|
||||
|
||||
```
|
||||
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py tests/test_issue30_stall_watch.py
|
||||
tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py
|
||||
tests/test_issue5_phase1_delegation.py tests/test_issue19_subscriber_daemon_lifecycle.py
|
||||
(.mam.env 미export, MQTT_* 미설정, followups .venv)
|
||||
→ 352 passed, 1 skipped in 488.95s failed 0 (skip = S6, /proc)
|
||||
```
|
||||
|
||||
- 브리프 목록 7파일에 두 파일을 더했다. `test_issue5_phase1_delegation.py` 는 CLI 스냅샷이고, `test_issue19_subscriber_daemon_lifecycle.py` 는 F4b 로 바뀐 `reconcile.sh` 의 `RECONCILE_POLL_INTERVAL` 을 거치는 구독자 데몬 수명 시험이다.
|
||||
- 로그에 외부 브로커 흔적 0건.
|
||||
- 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `75f364e`), 본 체크아웃 `main 0051fd6`.
|
||||
|
||||
## 3. 판정
|
||||
|
||||
1회차 비차단 관찰(T1 `test_o2` 의 미사용 `get_lstart`, T2 reconcile 내장 구독자의 트레이스백, T3 설치 확인 목록)은 범위 밖으로 그대로 열려 있다. 버전 bump 는 판정에서 제외한다. 병합을 막을 결함은 없다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,30 @@
|
||||
# 리뷰 리포트 — Job 3a93c30b (PR #48 2회차, 35935f9..e8d9151)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261005T081844Z-e92124 / base c5c7eff / head e8d9151 / tree 8b39188 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 36f0b8d, e8d9151. `git diff --stat 35935f9 e8d9151`: create SKILL.md 1줄, VERSIONS.md, 테스트 3개. lib.sh·lib_py·스크립트 변경 없음(제품 코드 불변).
|
||||
|
||||
## 결론 요약
|
||||
1회차의 차단 3건(B1~B3)과 문서 지적(D1)이 모두 닫혔다. 이 macOS 호스트(Python 3.9 venv)에서 지정 6개 파일이 실패 없이 통과하고, 실제 herdr 서버 테스트 5개가 skip 없이 돈다. 차단 결함 없음.
|
||||
|
||||
## 1. 1회차 지적 종결
|
||||
| 지적 (1회차) | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| B1 test_issue45_herdr_config.py:16 최상단 `import tomllib` → 3.9 에서 수집 중단 | try/except 로 선택적 import(:18-21), 결과를 파싱하거나 설정 유지를 기대하는 테스트만 `needs_tomllib` skip(:36) | 3.9: 수집 정상, tomllib 의존 검사만 skip. Python 3.11(Homebrew + PYTHONPATH): **51 passed**. 3.9 에서 남는 22건 중 test_without_tomllib_restore_is_still_off(:91-96)가 tomllib 없을 때 MINIMAL + "no tomllib" 경고를 직접 확인하므로 3.9 경로도 검사된다 |
|
||||
| B2 test_issue45_restore.py `/proc` 와 `/tmp` | 루트 `.resolve()`(:175), 서버 환경은 `/proc` 가 있으면 거기서, 없으면 `ps -E -ww`(:160-169, :239-240) | test_issue45_restore.py 5개 모두 **실행·통과**(skip 아님). 1회차에 내가 스크래치 사본으로 확인한 것과 같은 두 수정이며, 그때 A·B 수정을 끄면 각각 실패함(A: `--resume <id>`, B: pane 2개)을 확인했다. 제품 코드가 그 뒤 바뀌지 않았으므로 대조 결과는 그대로 유효하다 |
|
||||
| B3 test_issue9_server_env.py:157 tomllib, bootstrap 의 python3 가정 | bootstrap 과 같은 PATH 의 `python3` 로 결과를 파싱, tomllib 이 없으면 키 하나짜리 파일 + `confirm_close` 없음 + 경고 "no tomllib" 를 기대(:169-183) | 통과. 이 호스트에서는 else 분기(3.9)가 실행된다. `subprocess.run(["python3"…], env=env)` 는 POSIX 에서 env 의 PATH 로 실행 파일을 찾으므로 bootstrap 과 같은 인터프리터다. 경고 문구는 herdr_config.py:147 와 일치 |
|
||||
| D1 (비차단) python3 < 3.11 이면 사용자 설정 없이 뜬다는 문서 부재 | create SKILL.md:120 와 VERSIONS 4.8.5 항목 1, 품질 검증 항목에 추가 | 내용은 코드(lib.sh:241 의 bare `python3`, herdr_config.py:146-147)와 일치 |
|
||||
|
||||
## 2. 테스트 실행
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| 지정 6개 파일 (pr-48 .venv, Python 3.9.6) | **152 passed / 30 skipped / 0 failed** (216 s). skip = tomllib 필요 29건 + .claude/skills 1건(워크트리 정상). test_issue45_restore 는 skip 0 |
|
||||
| tests/test_issue45_herdr_config.py (python3.11) | **51 passed** |
|
||||
- 격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach 클라이언트와 오케스트레이터의 `agent wait` 뿐(잔류 서버 없음), `/private/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 목록 불변. MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 에 새 항목 없음.
|
||||
|
||||
## 3. 비차단 관찰
|
||||
- **N1 VERSIONS.md 4.8.5 항목 1 문장 흐름**: 새 문장이 "…경고가 남는다:" 로 끝나고 바로 뒤에 원래 있던 "출력이 원본 자신(별칭 포함)이거나 … 원본별 이름을 쓴다" 가 이어져, 출력 이름 규칙이 Python 버전 설명의 결과처럼 읽힌다. 원래는 "사용자 파일은 고치지 않는다:" 뒤에 붙던 설명이다. 새 문장을 항목 끝으로 옮기면 된다.
|
||||
- **N2 `_process_env` 의 macOS 분기**(test_issue45_restore.py:166-169)는 `ps -E` 출력을 공백으로 나누므로 공백이 든 경로는 맞추지 못한다. 테스트 루트가 `/private/tmp/m45…` 로 고정이라 지금은 영향 없다.
|
||||
- **N3 tomli 백포트 대신 문서화한 결정**에 대한 의견: 복원은 어느 경우에도 꺼지므로(#45 의 핵심) 안전 측면에서 문제없고, 이 호스트의 herdr 설정이 키 하나뿐이라는 사정에서 합리적이다. 나중에 사용자 설정이 커지면, 새 의존성 없이 PATH 에서 3.11+ 인터프리터를 찾는 방법도 고려할 만하다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,45 @@
|
||||
# 리뷰 리포트 — Job 40f4077c (PR #47, fix/issue-42-skills-link)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261004T043243Z-baf133 / base 6af4a79 / head e1ed270 / tree 2f781e0 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
|
||||
## 결론 요약
|
||||
수정은 두 배치(단일 링크 / 스킬별 링크 디렉터리)를 모두 올바르게 받고, 잘못된 배치는 실패시킨다. 차단 결함 없음. 비차단 관찰 2건.
|
||||
|
||||
## 1. 범위 (항목 3)
|
||||
`git diff --stat 6af4a79 e1ed270`: tests/test_issue42_skill_blocks.py 한 파일, +10/−1 (test_claude_skills_link_resolves_to_agents_skills, :240-253). 제품 코드·버전 변경 없음.
|
||||
|
||||
## 2. 배치별 동작 (항목 1)
|
||||
검증 경로 안에 임시 .claude/skills(.gitignore:40 의 `.claude/` 로 무시됨)를 만들어 이 테스트만 돌리고 매번 지웠다. 시작 전 .claude 가 없음을 확인했고, 끝난 뒤 `.claude` 잔여물 없음·`git status --short --ignored` 비어 있음·HEAD 불변을 확인했다. 본 체크아웃의 .claude/skills 는 `ls -la` 로 읽기만 했다(스킬 8개 링크 + lib_py·lib.sh 링크 = 10개 항목, 모두 ../../.agents/skills/<이름>).
|
||||
|
||||
| 배치 | 결과 | 판단 |
|
||||
|---|---|---|
|
||||
| A. 없음 | skip | 의도대로 |
|
||||
| B. 단일 링크 → .agents/skills | pass | 의도대로 |
|
||||
| C. 스킬별 링크 8개 + lib.sh·lib_py 링크(본 체크아웃과 같은 배치) | pass | 의도대로 |
|
||||
| C-old. 같은 배치에서 base(6af4a79) 의 테스트 | **fail** | 수정 대상 결함 재현 |
|
||||
| C2. C + 여분 디렉터리(foreign-skill) | pass | 여분 허용 |
|
||||
| D. stop 링크가 loop 를 가리킴 | fail (`.claude/skills/multi-agent-mux-stop -> …/multi-agent-mux-loop`) | 의도대로 |
|
||||
| E. 스킬 링크 하나 없음 | fail (`… is missing`) | 의도대로 |
|
||||
| F. 스킬 링크 하나가 깨짐 | fail (`… is missing`) | 의도대로 |
|
||||
| G. 링크 대신 복사본 디렉터리 | fail (resolve 가 .claude 쪽) | 의도대로 — 복사본은 드리프트하므로 막는 것이 맞다 |
|
||||
| H. .claude/skills 가 일반 파일 | fail (`… is missing`) | 의도대로 |
|
||||
| J. 단일 링크가 다른 곳(스킬 하나)을 가리킴 | fail | 의도대로 |
|
||||
| K. 빈 디렉터리 | fail | 의도대로 |
|
||||
| L. 스킬별 링크가 중간 링크를 거쳐 해석됨 | pass | resolve() 가 최종 대상을 비교하므로 맞다 |
|
||||
| I. **단일 링크가 깨짐** (`.claude/skills -> ../nowhere`) | **skip** | 관찰 N1 |
|
||||
|
||||
- SKILL_NAMES 가 비면 스킬별 루프가 공집합이라 통과하겠지만, 같은 파일의 test_inventory_covers_eight_skills 가 `len(SKILL_NAMES) == 8` 을 단언하므로(파일 상단 SKILL_NAMES 정의와 함께) 놓치지 않는다.
|
||||
|
||||
## 3. 엄격성 (항목 2)
|
||||
- 여분 항목(lib_py, lib.sh, 다른 스킬 디렉터리)은 허용된다(C, C2). Claude Code 가 스킬로 읽는 것은 SKILL.md 를 가진 하위 디렉터리이므로 여덟 MAM 스킬이 .agents/skills 의 것인지만 보는 현재 기준이 적절하다. 다른 스킬을 같이 두는 정상 사용을 막지 않는다.
|
||||
- lib.sh·lib_py 링크는 검사하지 않는데, 진입 스크립트는 `$SCRIPT_DIR/../..` 에서 lib.sh 를 찾고 없으면 `${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh` 로 폴백하므로(예: reconcile.sh:20-21) 테스트의 필수 조건으로 둘 이유는 없다. 문서의 호출도 모두 `bash .agents/skills/...` 경로를 쓴다.
|
||||
|
||||
## 4. 비차단 관찰
|
||||
- **N1 깨진 단일 링크가 skip 으로 숨는다** (tests/test_issue42_skill_blocks.py:245-246): `link.exists()` 는 링크를 따라가므로 대상이 없는 단일 링크는 "없음"으로 보고 skip 한다. 이 경우 Claude Code 는 스킬을 하나도 못 읽는데 테스트는 통과(skip)한다. base 테스트도 같은 동작이었으므로 이 PR 의 회귀는 아니다. `if not link.exists() and not link.is_symlink(): skip` 으로 바꾸면 깨진 링크는 아래 resolve 비교에서 실패한다.
|
||||
- **N2 실패 메시지**: G(복사본)의 메시지가 `-> <.claude 안 경로>` 로 나와 "링크가 아니라 복사본"이라는 원인이 바로 드러나지 않는다. 사소함.
|
||||
|
||||
## 5. 테스트
|
||||
검증 경로에서 `tests/test_issue42_skill_blocks.py` 전체: **31 passed / 1 skipped** (skip = 이 테스트, 워크트리에 .claude 없음 — 정상). 배치 탐침은 §2.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,76 @@
|
||||
# 리뷰 리포트 — Job 416d9768 (PR #48, fix/issue-45)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261005T064728Z-a4e9c8 / base c5c7eff / head 35935f9 / tree dff9db1 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
|
||||
## 결론 요약
|
||||
제품 동작(A: MAM 서버 복원 꺼짐, B: 자기 세션의 빈 셸만 닫기)은 이 macOS 호스트의 실제 herdr(0.9.1)에서 동작함을 확인했다. 그러나 **브리프가 지정한 검증 명령이 이 호스트(리포지토리 .venv = Python 3.9.6)에서 수집 단계에서 중단**되고, 그 파일을 빼고 돌려도 **새 테스트 5개가 macOS 에서 실패**한다. 원인은 모두 테스트의 Linux·Python 3.11 가정이며 수정은 작다. 스킬 frontmatter 가 `platforms: [linux, macos]` 이므로 차단으로 판단한다.
|
||||
|
||||
## 차단 (테스트 이식성)
|
||||
### B1. tests/test_issue45_herdr_config.py:16 — 모듈 최상단 `import tomllib`
|
||||
- Python 3.9 에서 `ModuleNotFoundError` → pytest 가 "Interrupted: 1 error during collection" 으로 **세션 전체를 중단**한다. 브리프 지정 명령(7개 파일)은 결과 0건, rc 2. 오케스트레이터의 전체 스위트도 같은 .venv 면 같은 이유로 중단된다.
|
||||
- 제품 쪽(lib_py/herdr_config.py:54-57)은 tomllib 부재를 처리하는데 테스트만 강제한다. `tomllib = pytest.importorskip("tomllib")` 처럼 모듈 skip 또는 tomllib 이 필요 없는 경우만 남기는 처리가 필요하다.
|
||||
- 참고: 같은 파일을 Homebrew python3.11 + 기존 venv 의 pytest(PYTHONPATH)로 돌리면 **51 passed**. 내용 자체는 정상이다.
|
||||
|
||||
### B2. tests/test_issue45_restore.py:227, :163 — Linux 전용 가정 (macOS 4 failed)
|
||||
| 테스트 | 실패 | 원인 |
|
||||
|---|---|---|
|
||||
| test_a_…[plain-user-config], test_a_…[user-config-with-session-table] | FileNotFoundError `/proc/<pid>/environ` | :227 `/proc` 는 macOS 에 없다 |
|
||||
| test_b_only_the_resumed_sessions_restored_shell_is_closed | `'/private/tmp/m45…' == '/tmp/m45…'` (:263) | :163 `mkdtemp(dir="/tmp")` — macOS 에서 /tmp 는 /private/tmp 링크이고 herdr 는 실경로를 보고한다 |
|
||||
| test_b_resume_leaves_no_restored_shell_behind | StopIteration (:325, cwd 비교) | 같은 원인 |
|
||||
- 검증: 스크래치 사본에서 **이 두 군데만** 고친 테스트(`.resolve()` 추가, `/proc` 대신 `ps -E -ww -o command= -p <pid>`)는 **5 passed**(65 s). 즉 제품은 맞고 테스트만 이식되지 않았다. 양성 대조도 이 사본에서 동작한다.
|
||||
|
||||
### B3. tests/test_issue9_server_env.py:157 — 테스트 안 `import tomllib`, 그리고 bootstrap 의 python3 가정
|
||||
- 3.9 에서 import 로 실패. 또 3.11 로 테스트를 돌려도 bootstrap 은 `python3`(lib.sh:241, 이 호스트는 /usr/bin/python3 3.9.6)로 herdr_config 를 실행하므로 결과는 MINIMAL 이고, :170 의 `cfg == {"ui": …, "session": …}` 단언은 실패한다. `python3` 에 tomllib 이 없으면 skip(또는 MINIMAL 을 기대)해야 한다.
|
||||
|
||||
## 검토 항목별 판단
|
||||
### 1. A 의 설정 병합 (lib_py/herdr_config.py)
|
||||
- 원본 보호: 출력은 항상 output_path(:192-205)를 거친다 — 원본 자신(samefile/realpath, 별칭 포함)이나 다른 원본용·비생성 파일이면 해시 이름으로, 그것도 막히면 OSError → 쓰지 않음. 폴백(.mam)도 같은 함수(:273, R3 2회차 수정). 쓰기는 같은 디렉터리 임시 파일 + os.replace(:208-222), 원본 권한 복사(:255).
|
||||
- 재귀: HERDR_CONFIG_PATH 가 생성 파일이면 2행의 `# source:` 로 원본을 따라간다(:65-92). source 행이 없으면 XDG/HOME 으로.
|
||||
- 병합 정확성: tomllib parse → `session` 에 키만 덮어씀 → dumps → 재파싱이 기대값과 **정확히 같을 때만** 사용(:156-164). 인라인 표·점 표기·따옴표 키·배열은 파싱 결과(dict)로 다루므로 표기와 무관. 주석·표기 형식은 생성 파일에서 사라지지만 원본은 그대로이고 의미는 같다. 실패 시 MINIMAL + 경고.
|
||||
- herdr 검사: 쓴 뒤 `herdr config check`(:169-180)가 "parse error"/"using defaults" 를 내면 MINIMAL 로 재시도, 그것도 거부되면 None → bootstrap 이 경고하고 예전처럼(복원 켜짐) 기동(lib.sh:241-245). 단위 테스트가 실제 herdr 로 이를 확인(3.11 에서 51 passed).
|
||||
- 사용자 디렉터리 흔적: `~/.config/herdr/mam-server-config.toml`(또는 해시 이름) 1개, 중단 시 `.mam-server-config.*.tmp` 가능. create SKILL.md:120-121 에 위치가 적혀 있다.
|
||||
- 비차단 D1 (아래): python3 < 3.11 호스트에서는 병합이 일어나지 않는다.
|
||||
|
||||
### 2. 적용 경로
|
||||
- MAM 이 서버를 띄우는 곳은 shim 의 headless bootstrap 한 곳(lib.sh:261 `"$REAL_HERDR" --session "$_MAM_SESSION" server`)뿐이고 A 가 여기에 걸려 있다. create·resume·delegate 는 모두 shim 을 거친다. 다른 `server` 기동은 grep 으로 찾지 못했다. MAM_NO_AUTOSTART 호출자(status/reconcile)는 서버를 띄우지 않는다(lib.sh:196-201).
|
||||
- 걸리지 않는 경로: 사용자가 `herdr session attach`/`herdr --session X` 로 직접 띄운 서버, 기본 `default` 서버, 이미 떠 있는 서버. create SKILL.md:121 이 "rebuilt each time MAM starts the server … next started", "A server you start yourself (`herdr session attach`) does not get it" 로 적고 있다(합의된 한계와 일치).
|
||||
|
||||
### 3. B 의 닫기 조건 (lib_py/herdr_restore.py)
|
||||
- 조건(:54-65): 세션 id 비어 있지 않음, 서버가 default 아님, `agent` 없음, `agent_session.value == id`, 전경 = shell_pid(shell_pid 없으면 False). 사용자 pane(agent_session 없음)·다른 세션·전경 프로세스 있음·에이전트 살아 있음은 제외 — 실제 서버 테스트(사본)에서 사용자 셸·Z(실행 중)·X2(sleep 60)가 남고 X·Y 만 닫힘을 확인.
|
||||
- fail-safe: herdr 오류·JSON 오류는 예외 → exit 1(:79-83) → resume 은 경고만(resume_session.sh:240-242) 하고 진행. default 서버는 herdr 를 부르지도 않는다(test_b_default_server_is_never_touched 통과). Class A fresh-spawn(UUID 없음)은 즉시 반환.
|
||||
|
||||
### 4. 테스트의 격리·결정성
|
||||
- test_issue45_restore 는 HOME 을 `/tmp/m45*` 로, 세션을 `mamt45-<pid>-<4hex>` 로 두고 HERDR_*·XDG_CONFIG_HOME·HERDR_CONFIG_PATH 를 지운다(:42-44, :161-168). 정리는 그 세션 이름·그 루트 경로로만 pkill(:186-195). conftest 도 HERDR_CONFIG_PATH·XDG_CONFIG_HOME 을 지운다.
|
||||
- 실행 전후 확인(실패 실행 3회, 사본 실행 3회 모두): herdr 프로세스는 기존 `multi-agent-mux` 서버·attach 클라이언트뿐(잔류 없음), `/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 목록 불변.
|
||||
- 양성 대조: 사본에서 lib.sh 의 `HERDR_CONFIG_PATH` 주입을 끄면 test_a 가 `--resume <id>` 로 실패(#45 재현), resume 의 close 호출을 끄면 test_b_resume 가 pane 2개로 실패. 테스트가 수정에 실제로 의존한다.
|
||||
- 결정성: 폴링 + 고정 대기(최대 수 초)라 느린 호스트에서 다소 민감할 수 있으나 3회 반복 모두 같은 결과였다.
|
||||
|
||||
### 5. 문서·인벤토리
|
||||
- 버전: lib.sh·eager.py·SKILL.md 8개·VERSIONS 가 4.8.5 로 일치(test_version_consistency 통과).
|
||||
- skill_code_blocks.json: blocks 의 (skill, class, kind) 순서 동일, create 블록 4개의 start/end 이동과 resume removed 항목의 evidence 줄 번호(+8) 이동뿐. 분류 변경 없음.
|
||||
- **D1 (비차단, 문서)**: VERSIONS 4.8.5 항목 1과 create SKILL.md:120 은 서버 설정을 "사용자 설정 + 키"로 설명하지만, bootstrap 은 `python3`(lib.sh:241)로 실행되므로 python3 < 3.11 인 호스트(이 macOS 의 /usr/bin/python3 3.9.6)에서는 **항상 키 하나만 있는 파일**로 기동하고 사용자 herdr 설정(예: default_shell·키 바인딩)이 MAM 서버에 적용되지 않으며, 기동마다 경고가 나온다. 복원은 꺼지므로 안전하지만, 이 한계를 문서에 적거나 3.11+ 인터프리터를 찾는 편이 좋다. VERSIONS 의 "실패 시 키 하나만" 목록에도 Python 3.11 미만이 빠져 있다.
|
||||
|
||||
### 6. purge 기준 실패
|
||||
검증 경로에서 tests/test_issue10_13_purge.py **23 passed**(macOS). 이 PR 과 무관하며 "12 실패"는 Linux 기준이다.
|
||||
|
||||
### 7. 경계(#17)
|
||||
새 판정(병합·검사·출력 경로, 닫기 조건)은 모두 Python(herdr_config.py, herdr_restore.py)에 있다. bash 쪽 추가는 호출과 빈 값 경고(lib.sh:241-245, resume_session.sh:240-242)뿐이다.
|
||||
|
||||
## 테스트 실행 요약
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| 브리프 지정 7개 파일 (pr-48 .venv, Python 3.9.6) | **수집 오류로 중단, 0건 실행, rc 2** (B1) |
|
||||
| herdr_config 제외 6개 파일 | **5 failed** / 192 passed / 1 skipped (B2 4건, B3 1건; skip = .claude/skills, 워크트리 정상) |
|
||||
| test_issue45_herdr_config.py (python3.11) | 51 passed |
|
||||
| test_issue45_restore.py 이식성 2곳만 고친 사본 | 5 passed; A·B 수정을 끄면 각각 실패 |
|
||||
| test_issue10_13_purge.py | 23 passed |
|
||||
MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 에 새 항목 없음.
|
||||
|
||||
## 수정 제안 (최소)
|
||||
1. test_issue45_herdr_config.py:16 → `tomllib = pytest.importorskip("tomllib")`.
|
||||
2. test_issue45_restore.py:163 → `Path(tempfile.mkdtemp(...)).resolve()`; :227 → `/proc` 가 없으면 `ps -E -ww -o command= -p <pid>` 로 확인(또는 그 단언만 Linux 한정).
|
||||
3. test_issue9_server_env.py:157 → bootstrap 이 쓸 `python3` 에 tomllib 이 없으면 skip 하거나 MINIMAL 을 기대.
|
||||
4. (비차단) D1 문서화.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,43 @@
|
||||
# 리뷰 리포트 — Job 4566e690 (Issue #17 후속, fix/issue17-followups 56d0bfd..8241bd5)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 8241bd5 / tree efef640 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 커밋 6개: 25c9970·f19f940(항목 1), a670ef5(항목 2), d6cb019(항목 3), c90a0af(항목 4), 8241bd5(테스트 환경 고정). 버전·VERSIONS.md 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
항목 1~4 가 요구대로 반영됐고, 정상 경로의 종료 코드·출력·레지스트리·이벤트는 변하지 않았다. 추가 테스트 13건은 수정 전 코드에서 실제로 실패한다(255·127·2 등 예측된 코드로). 항목 1 에는 브리프 문구("테스트 전제 수정")를 넘는 **제품 코드 변경**(herdr_restore.py 의 placeholder 닫기)이 포함되는데, 실제 herdr 0.9.3 에서 그 변경 없이는 테스트도 resume 의 닫기도 성립하지 않음을 확인했으므로 정당한 범위 확장으로 판단한다(§1). 차단 결함 없음. 비차단 관찰 3건(§6).
|
||||
|
||||
## 1. 항목 1 — herdr 0.9.3 과 test_b
|
||||
- 브리프의 전제 "닫기 동작은 cwd 를 보지 않는다 → :275 만 두 경로 허용" 은 불완전했다. herdr 0.9.3 은 디렉터리가 사라진 pane 을 **프로세스 없는 placeholder**(`restore_error` 설정)로 복원하고 `pane process-info` 는 `pane_not_found` 로 rc 1 을 낸다. 실제 서버 대조: 스크래치 사본에 **이전 herdr_restore.py** 만 넣고 test_b 를 돌리면 `close-restored-shells: Command […'pane','process-info','--pane','w2:p1'] returned non-zero exit status 1` 로 실패(1 failed, 11.8 s) — 즉 옛 코드는 Y 의 placeholder 에서 예외를 내 resume 의 닫기 단계 전체가 rc 1 로 끝나고(래퍼는 경고만), 아무 pane 도 닫지 못한다.
|
||||
- 수정(herdr_restore.py:58-66 `_nothing_running`): `_shell_in_foreground` 가 CalledProcessError 를 내면 **pane 에 `restore_error` 가 있고 출력에 `pane_not_found` 가 있을 때만** "실행 중인 것 없음"으로 보고, 그 외 실패는 그대로 올린다. 두 조건이 모두 herdr 자신이 붙인 표지라 사용자 pane·다른 세션 pane 이 이 경로로 닫힐 수 없다(agent_session 일치 조건 :83 은 그 앞에서 여전히 적용). 단위 테스트 2건이 "restore_error 없음 + pane_not_found", "restore_error 있음 + 다른 오류" 를 모두 예외 유지로 고정한다. fail-safe 방향(닫는 쪽이 아니라 두는 쪽) 유지.
|
||||
- `_herdr` 는 `subprocess.run(..., capture_output=True, check=True)` 이므로 CalledProcessError.stderr/.output 이 채워진다(:46-49) — 판정에 쓰는 문자열이 실제로 들어온다.
|
||||
- test_issue45_restore.py:267·:275-277 주석·단언이 0.9.1($HOME)/0.9.3(원래 경로) 둘 다 허용. 다른 문서의 "$HOME 으로 복원" 주장: SKILL.md 들에는 없음(create:120, resume:63 은 "empty shells" 만 말함), VERSIONS.md 과거 항목은 브리프대로 제외.
|
||||
- 실행: 이 Mac(herdr 0.9.3)에서 tests/test_issue45_restore.py **5개 모두 통과, skip 0**.
|
||||
|
||||
## 2. 항목 2 — N2 fail-closed
|
||||
- 래퍼(:103-127): `_decision_failed` 는 stderr 에 사유를 적고 `exit 1`; `_decided <var> <sub> …` 는 rc≠0 또는 빈 출력이면 실패; `_exit_for` 는 답이 `^[0-9]+$` 가 아니면 실패. 다섯 `exit "$(_decide exit-code …)"` 가 `_exit_for` 로, 네 `sub-status` 대입과 `finalize-begin` 이 `_decided` 로, `finalize-end` 는 rc 검사(:856-857), `cmd_wait` 의 wait-rc 는 `_decided` + 숫자 검사(:1070-1071). 모든 호출이 서브셸 밖(직접 호출)이라 `exit 1` 이 래퍼를 끝낸다; `{ …; } 4>&1` 그룹도 서브셸이 아니다.
|
||||
- 정상 경로 불변: `_decided` 는 같은 명령의 같은 한 줄 출력을 변수에 넣고, `finalize-end` 의 "소유자 사라짐 → rc 0·빈 답" 은 전과 같이 FINAL_JOB_STATUS="" 로 남는다(의도된 기존 동작). test_f_the_normal_path_is_unchanged(실제 래퍼, human 잡, 닿지 않는 브로커 → rc 3, 마지막 줄 = 로그 경로, 오류 문구 없음) 통과. 옛·새 비교 테스트(test_c_*) 전부 통과.
|
||||
- rc 로만 읽는 호출: `subscriber-up` 실패(모듈 깨짐) → "subscriber died early" 경로 → `_decided sub-status` 가 실패 → exit 1(fail-closed, OK). `stall-watch` 실패 → `|| return 0` 으로 **감시만 꺼진 채 진행**(:718). 브리프가 "범위에 넣지 않은 것은 이유를 적으라" 했는데 리뷰어에게 그 이유는 보이지 않는다 — 관찰 N1. #30 감시자는 notify-only 라 잡 종결에는 영향이 없어 비차단.
|
||||
- 추가 테스트가 수정 전 코드에서 실패하는가: 이전 래퍼(56d0bfd)를 넣은 사본에서 test_f_* 11건 + test_s_no_printed_decision_is_read_raw 모두 **실패** — exit-code 는 `exit: : numeric argument required` 로 **255**, 인터프리터 부재는 **127**, finalize-begin 빈 답은 finalize-end argparse `--evidence-until: invalid int value: ''` 로 **2**. 세 가지 모두 브리프가 금지한 종료 방식이며 새 코드는 전부 1 로 끝난다.
|
||||
- 이식성: `/bin/bash -n` 통과(3.2), `printf -v`·`[[ =~ ]]` 는 3.2 에서 지원. 테스트는 `/bin/sh` 셸 스크립트 대역과 `sys.executable` 만 쓴다.
|
||||
|
||||
## 3. 항목 3 — N1 문구
|
||||
wrapper.py:24-29 가 "이 모듈은 stdlib 만 import 하지만 `python -m lib_py.delegation.wrapper` 는 패키지 `__init__` 을 먼저 실어 paho 가 로드되며, 래퍼의 $PY 는 원래 paho 가 필요하다"로 정정. 실행 경로 변경 없음(diff 상 docstring 만).
|
||||
|
||||
## 4. 항목 4 — N3 주석
|
||||
lib_int.sh:9-10 에 "PATH 의 python3 를 쓴다(단독 source 라 $PY 없음); 없으면 mam_int/mam_int_knob 이 아무것도 출력하지 않는다(rc 127)" 추가. 코드 불변.
|
||||
|
||||
## 5. 테스트 실행 (검증 경로, 워크트리 .venv Python 3.9.6, paho 있음, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| tests/test_issue45_restore.py, test_issue45_restore_unit.py, test_issue17_item4_decisions.py, test_issue33_budget.py | **310 passed / 0 failed / 0 skipped** (425 s) |
|
||||
| 새 테스트 16건, 이전 래퍼·이전 herdr_restore.py 사본 | **13 failed** / 3 passed (실패 = F 11 + 정적 가드 + placeholder 단위) |
|
||||
| test_b (실서버), 이전 herdr_restore.py 사본 | 1 failed (pane_not_found 예외) |
|
||||
격리: 실행 전후 herdr 프로세스는 기존 `multi-agent-mux` 서버·attach·오케스트레이터 `agent wait`(pid 만 교체)뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml md5 불변. 실제 .mam/jobs 항목 수 불변. 루프백 bind 제약 없음(브로커 테스트 정상 실행).
|
||||
|
||||
## 6. 비차단 관찰
|
||||
- **N1 stall-watch 판정 실패 시 조용히 감시 생략**(래퍼 :718 `|| return 0`): 모듈이 깨지면 stderr 에 아무 말 없이 stall watcher 가 꺼진다. 종결·레지스트리·이벤트에는 영향이 없어 fail-closed 요구 밖이지만, `_decide` 의 rc 가 1(판정 "감시 안 함")인지 그 밖(실패)인지 구분해 후자는 경고 한 줄을 남기면 진단이 쉬워진다. 브리프가 요구한 "범위 제외 이유" 가 리포트에 있는지 오케스트레이터가 확인하길 권한다.
|
||||
- **N2 `_env` 가 상속 WORKSPACE_ROOT 를 비우지 않음**(tests/test_issue17_item4_decisions.py:224-230): HERDR_/MAM_/SKS_ 만 걷어낸다. 새 테스트(:659-660, :692)는 WORKSPACE_ROOT 를 명시하지만, 래퍼를 source 하는 기존 `_run_both` 4곳(:246, :372, :561, :586, PR #51 유래)은 `_env(work, …)` 만 써서 에이전트 pane 에서 돌리면 그 pane 의 WORKSPACE_ROOT 로 lib.sh 가 초기화된다 — 브리프가 적은 사고(상속된 WORKSPACE_ROOT 로 다른 워크스페이스의 shim 재작성)와 같은 경로다. `_env` 에서 WORKSPACE_ROOT 를 제거하고 tmp_path 로 고정하면 한 줄로 막힌다. 이 델타가 만든 문제가 아니라 비차단.
|
||||
- **N3 docstring 줄바꿈**(herdr_restore.py:7-8): "A pane keeps the\n`agent_session` its agent\nreported" 로 어색하게 끊겨 있다. 내용 문제 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261017T145452Z-6c5aa6",
|
||||
"base_sha": "26e2c43753169671b600d03274901dbe203988dd",
|
||||
"head_sha": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
|
||||
"tree_sha": "ffc291e6fc93822070f2b9f1d17009e83f176fcf",
|
||||
"review_commit": "de6f3fc81f8c33a9d2335320742dc5a47f566cda",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "48d0a01c",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-17T15:16:54Z"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# 리뷰 리포트 — PR #59 3차 (Issue #31) / job 48d0a01c
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T145452Z-6c5aa6/1`
|
||||
- identity: HEAD `de6f3fc81f8c33a9d2335320742dc5a47f566cda`, tree `ffc291e6fc93822070f2b9f1d17009e83f176fcf` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
|
||||
- 범위: 26e2c43..de6f3fc 전체. 2a8d738 이후 변경은 de6f3fc 한 커밋으로, `tests/test_issue31_purge_residual.py` 의 +3/−1 뿐이다. `git diff 2a8d738 HEAD -- .agents` 는 비어 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
2차의 유일한 차단(T1: macOS `/bin/sh` 의 단일 명령 exec 로 보유자 argv 에서 uuid 가 사라지던 문제)이 해결됐다. 제품 코드는 2a8d738 과 바이트 단위로 같으므로, 2차에서 이 Mac 에서 확인한 내용이 그대로 유효하다. 확인한 항목은 비-UUID 거부, 링크 경계, 유한하지 않은 startedAt, UTC lstart, exit 4 안내, 삭제 범위, 실제 `~/.claude`·`/private/tmp/claude-501` 레이아웃 정합이다. 판정은 **PASS**.
|
||||
|
||||
## (a) de6f3fc 검증
|
||||
|
||||
1. **보유자 argv 에 uuid 가 남는다.**
|
||||
- 새 보유자는 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 이다(`tests/test_issue31_purge_residual.py:317`).
|
||||
- Python 인터프리터는 `-c` 뒤 인자를 `sys.argv[1]` 로 받을 뿐 exec 로 자신을 바꾸지 않으므로, `ps` 의 args 에 소문자 `conv` 가 그대로 남는다.
|
||||
- `subprocess.Popen` 은 자식의 exec 성공을 확인한 뒤에 반환하므로, 이어지는 `time.sleep(0.05)` 시점에는 argv 가 이미 보인다(경합 없음).
|
||||
- `sys` 는 파일 머리(`:22`)에서 이미 import 되어 있다. 같은 패턴인 `test_issue10_13_purge.py` 의 `_holder()` 와도 일관된다.
|
||||
2. **upper/mixed 에서 통과한다.** 검증 경로에서 `-k any_spelling` 을 5회 반복 실행해 5회 모두 `2 passed` 였다. 관련 모듈 전체 실행에서도 통과했다(아래).
|
||||
3. **철자 처리 회귀를 여전히 잡는다.** `git clone` 사본에서 claude 어댑터를 한 군데씩 변형해 test 파일 전체를 돌렸다.
|
||||
- `conversation_in_use` 의 argv 검색을 `{canon, uuid}` 에서 `{uuid}` 로 바꿈(소문자 정규화 제거): 2 failed (upper, mixed). 실패 지점은 정확히 새 보유자 단언 `:320`, `AssertionError: a command line naming the lowercase id` 이다. 즉 수정된 보유자가 이 회귀를 잡는 바로 그 장치다.
|
||||
- `_session_files(canon)` 을 `_session_files(uuid)` 로 바꿈: 2 failed (세션 파일 경로 단언에서).
|
||||
- `_canonical` 의 `.lower()` 제거: 2 failed.
|
||||
- 세 변형 모두 나머지 65개는 통과했으므로, 실패가 해당 테스트에 국한된다.
|
||||
4. **정리 동작이 유지된다.**
|
||||
- `try/finally: named.kill(); named.wait()` 구조는 그대로다(`:318-323`).
|
||||
- 변형 실행(일부러 실패시킨 경우 포함)이 끝난 뒤 `ps -axo pid=,args=` 에서 `python … import time; time.sleep(120)` 프로세스는 0개였다. argv0 가 python 인 행만 세어 자기 자신과의 매칭은 배제했다.
|
||||
|
||||
## (b) 전체 판정을 위한 재확인
|
||||
|
||||
- 제품 파일(`.agents/` 전체)은 2a8d738 과 동일하므로, 2차 리뷰(job 778fc866)의 확인 결과를 그대로 승계한다. 다시 돌린 것은 위 변형 대조와 아래 관련 모듈이다.
|
||||
- 알려진 비차단 세 가지는 브리프대로 병합 차단이 아니다. 내용은 2차 리포트에 적은 그대로다.
|
||||
- 링크된 `projects/<key>` 아래 jsonl 은 purge 되지 않고 residual 로만 보고된다.
|
||||
- 소문자 질의는 대문자 argv 보유자를 보지 못한다.
|
||||
- `purge_artifacts` 를 직접 호출하면 in-use 게이트를 거치지 않는다.
|
||||
|
||||
## 테스트
|
||||
|
||||
검증 경로에서 `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider` 로 실행했다.
|
||||
|
||||
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
|
||||
- 결과: **332 passed, 1 skipped, 1 error** (624 s). 2차의 2 failed 는 사라졌다.
|
||||
- 1 error 는 `test_tier2_component.py::test_comp_codex_create_and_reconcile_and_stop` 의 **teardown 에서 #55 가드**가 낸 것으로, 브리프가 밝힌 알려진 오탐이다. 판정 근거로 쓰지 않고 보고만 한다.
|
||||
- 가드가 잡은 경로: `~/.gemini/antigravity-cli/conversations/0dd4a87f-6170-472b-886a-eef6e099b375.db-shm` 과 `.db-wal`.
|
||||
- 읽기 전용 확인 결과: 이 DB 는 다른 곳에서 돌고 있는 실제 `agy` 프로세스(pid 66733)가 열고 있다(`lsof -t`). WAL 이 자라는 중이다(mtime Oct 18 00:06, 크기 19.6 MB). 테스트 산출물이 아니다.
|
||||
- 이 테스트는 2차 실행에서는 통과했고, 이번 커밋은 tier2 와 무관한 테스트 한 줄만 바꿨다.
|
||||
|
||||
## 비차단 메모
|
||||
|
||||
- 2차 리포트의 비차단 사항(`projects/<key>` 링크 시 동작 문서화 등)은 그대로다. 병합 후 별도 이슈로 다뤄도 된다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261013T025803Z-c69408",
|
||||
"base_sha": "42e2a7ea9bd3e2770ed8626d789193428ca3aa8b",
|
||||
"head_sha": "3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db",
|
||||
"tree_sha": "bd43e4642f0109091f5f497b2703dcbaf8e2616b",
|
||||
"review_commit": "3a30d7da894cdac26b7ec054b4e66fe9fa3dc8db",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "495a1373",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-13T03:32:19Z"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# 테스트 격리 수정(u1e 의 WORKSPACE_ROOT) 리뷰 — 커밋 3a30d7d (job 495a1373)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261013T025803Z-c69408 / base 42e2a7ea .. head 3a30d7da (커밋 1개, 테스트 2 파일) / tree bd43e464
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025803Z-c69408/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, **`.mam` 없음**(시작·종료 모두). 본 체크아웃 HEAD 42e2a7e(main) 변동 없음.
|
||||
- 실행 안전: 모든 실행은 `env -i`(PATH/HOME/LANG/TMPDIR 만)로 브리프의 `-u` 목록을 포함한 라우팅 변수를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수정 전 재현은 검증 경로를 `git clone --no-hardlinks` 한 임시 복사본에서만 했고 거기 생긴 `.mam` 은 지웠다. 검증 경로에서 돌린 것은 W1/W2 뿐이며(W1 은 체크아웃을 tmp 로 복사해 그 안에서 노드를 돌린다), 끝난 뒤 검증 경로에 `.mam` 이 없음을 확인했다. 실제 `.mam`·`.mam.env`·모니터·실제 서버·에이전트 홈 미접촉.
|
||||
|
||||
## 1. 원인과 수정
|
||||
- 원인(1차 README 리뷰에서 재현한 그대로): `test_u1e` 는 래퍼를 `subprocess.run(["bash", WRAPPER, "submit", …, "--dry-run"], env=dict(os.environ, HOME=…, MAM_ENV_FILE=…), cwd=tmp_path)` 로 부르는데, 래퍼가 source 하는 `lib.sh` 는 `WORKSPACE_ROOT` 가 없으면 체크아웃을 기본값으로 삼고(`lib.sh:26`) `_init_herdr_isolation` 이 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 쓴다(`lib.sh:150-154`). conftest 의 `pytest_configure` 는 다른 체크아웃을 가리키는 `WORKSPACE_ROOT` 만 떨어뜨리므로 pane 밖에서는 변수가 없고, 체크아웃이 기본값이 된다.
|
||||
- 수정(`tests/test_issue50_dispatch.py:167-170`): env 에 `WORKSPACE_ROOT=str(tmp_path)` 추가 → shim 은 `tmp_path/.mam/shim/herdr` 에 쓰인다. `tests/test_issue17_workspace_root_isolation.py:43`: 이 노드를 W1 `NODES` 에 추가. 두 파일 `py_compile` 통과. 같은 파일의 다른 테스트는 `decide.*` 를 in-process 로 부르므로(`_register`/`_edit`/`_event` 헬퍼) shim 을 쓰지 않는다 — 아래 §2 의 파일 전체 실행이 이를 확인.
|
||||
|
||||
## 2. 실행 결과
|
||||
|
||||
| 실험 | 결과 |
|
||||
|---|---|
|
||||
| **수정 전 복사본**(검증 경로 clone + `test_issue50_dispatch.py` 를 base 42e2a7e 로 되돌림): u1e 단독 | 1 passed, 복사본에 **`.mam/shim/herdr` 생성** — 원인 재현 |
|
||||
| **수정 전 복사본**: W1(새 NODES 포함) | **FAIL**: `AssertionError: tests wrote into the checkout they ran in: ['.mam/shim', '.mam/shim/herdr']` — W1 이 이 노드로 회귀를 잡는다(복사본 안의 tmp 복사본에만 쓰였고 복사본 자체에는 `.mam` 없음) |
|
||||
| **head, 검증 경로**: `test_issue17_workspace_root_isolation.py` (W1+W2) | **2 passed**, 검증 경로 `.mam` 없음 |
|
||||
| **head 복사본**(`git archive`): `test_issue50_dispatch.py` 전체 | **49 passed**, 복사본 `.mam` 없음 |
|
||||
|
||||
W1 이 잡는 원리: W1 은 복사본에 `WORKSPACE_ROOT=<복사본>` 을 주고 노드를 돌린다. 수정 전 u1e 는 `os.environ` 을 그대로 물려주므로 래퍼가 그 복사본에 shim 을 쓰고, 수정 후는 `tmp_path` 로 덮어써 복사본에 아무것도 남지 않는다. pane 밖(변수 없음)에서도 lib.sh 의 체크아웃 기본값 때문에 같은 결과이므로 두 환경 모두를 대표한다.
|
||||
|
||||
## 3. 같은 결함이 남은 테스트가 있는가
|
||||
- 정적: 래퍼·`lib.sh`·`run_loop.sh`·`create_session.sh`·`reconcile.sh`·`stop/resume_session.sh` 를 subprocess 로 부르는 테스트 파일 59 개를 추려 `mam_sandbox`/`own_workspace_root`/`WORKSPACE_ROOT` 참조 여부를 봤다. 참조가 하나도 없는 7 파일과, 참조가 적어 테스트별 누락이 있을 수 있는 18 파일(W1 NODES 에 없는 것 위주)을 골랐다.
|
||||
- 경험적: 그 **25 파일을 head 의 `git archive` 복사본에서 파일마다 따로** 돌리고 매번 복사본의 `.mam` 을 봤다 — `test_b7_diff_untracked`, `test_deploy_freshness`, `test_deploy_layout`, `test_issue42_skill_blocks`, `test_issue45_herdr_config`, `test_issue5_phase0_contracts`, `test_local_mqtt`, `test_issue14_delivery_ack`, `test_issue18_liveness_contract`, `test_issue6_turn_handshake`, `test_issue26_envfile`, `test_issue26_ints`, `test_issue33_budget`, `test_layout`, `test_workspace_scope`, `test_a4_adapter_contract`, `test_issue45_restore`, `test_sanitize_and_mock_errors`, `test_sanity`, `test_issue12_registry_secrets`, `test_issue21_acceptance`, `test_issue15_registry_isolation`, `test_issue20_state_isolation`, `test_issue36_prompt_transport`, `test_issue34_job_tree`: 전부 통과(skip 은 tomllib/nats-docker 류), **`.mam` 생성 0 건**.
|
||||
- 이전 리뷰들에서 같은 방식으로 돌린 파일들(leak·startup_path·shim_contract·issue17/9/45, scenario4, pane_lock, issue50_dispatch_herdr 등)도 검증 경로에 `.mam` 을 남기지 않았고, `.mam/shim` 이 생긴 경우는 모두 `test_issue50_dispatch.py` 가 포함된 실행뿐이었다(u1e). 즉 이 결함은 u1e 하나였고, 수정으로 닫혔다.
|
||||
|
||||
## 4. 잔류물
|
||||
- clone 복사본·archive 복사본·basetemp·로그는 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
|
||||
|
||||
## 결론
|
||||
원인 진단이 맞고, `WORKSPACE_ROOT=tmp_path` 가 정확한 최소 수정이며, W1 은 수정 전 트리에서 이 노드로 실패하고 수정 후 통과한다. 다른 테스트에서 같은 누출은 25 파일의 파일별 실행으로 재현되지 않았다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,131 @@
|
||||
# 🔍 Review Report — PR #37 병합 전 독립 리뷰 (job `4ab12ae2`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||
- **대상**: PR #37 `fix/issue-36` **@ `59be1c8`**, base `main 1debc7d` (v4.7.2). 6커밋, 11파일 `+765/−30`, `git diff --check` 클린.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260924T080014Z-2b3df0/1`. **identity 확인**: `HEAD = 59be1c89…cb49`, `HEAD^{tree} = a009c906…fab7` — 브리프와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-37`)·본 체크아웃(`main 1debc7d`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4)에서만(래퍼는 `--dry-run` + `--agent human` + 도달 불가 브로커, 러너는 in-process 스텁), pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**병합을 막을 결함은 없다.** 프롬프트는 러너·래퍼·lib.sh 도우미·옛 셸 러너·create 온보딩 다섯 경로 모두에서 파일로 간다(`grep -rn -- '--prompt '` 의 잔존 호출은 전부 셸 **함수** 인자이거나 문서/기본값이다). 비차단 지적 4건(H1–H4). 그중 **H1 은 재현한 실제 결함**이다: 파이썬 러너는 프롬프트 파일 **쓰기**가 실패하면(ENOSPC 등) 부분 파일을 `.mam` 에 남기고 예외를 그대로 던진다 — PR 이 셸 쪽 두 곳에서 고친 바로 그 부류가 러너에는 빠져 있다. 디스크 고갈 급의 경계 상황이라 차단으로 보지 않았지만, 릴리스 커밋이나 즉시 후속에서 세 줄로 닫을 것을 권한다.
|
||||
|
||||
## 1. 전달 경로의 완전성 (항목 1)
|
||||
|
||||
| 경로 | 이전 | 이제 | 근거 |
|
||||
|---|---|---|---|
|
||||
| 파이썬 러너 `_run_cmd` | `--prompt <text>` argv | `.mam/prompt-<loop_id>-XXXX.md` 에 쓰고 `--prompt-file` | `runner.py:467-502` |
|
||||
| 래퍼 `submit` → `registry.py register` | `--prompt "$PROMPT"` | 파일로 왔으면 `--prompt-file` 그대로 전달, 텍스트로 왔으면 종전대로 | `multi-agent-mux-delegate-job:186-208` |
|
||||
| 래퍼 loop 타입의 회차 `update` | `--prompt "$current_prompt"` | `$job_dir/prompt.md` 에 쓰고 `--prompt-file` | `:405-450` |
|
||||
| `lib.sh` `delegate_submit_job` / `_delegate_submit_job_legacy` (제출 도우미 11곳의 종착) | argv | `_delegate_prompt_file` 로 `.mam/prompt-submit-XXXXXX` 에 쓰고 `--prompt-file`, 성공·실패 모두 `rm -f` | `lib.sh:2038-2046, 2049-2064, 2160-2174` |
|
||||
| `lib_py/delegation/cli.py submit` | `--prompt` 필수 | `--prompt`/`--prompt-file` 상호 배타, register 로 그대로 전달 | `cli.py:29-33, 64-66` |
|
||||
| 옛 셸 러너 `MAM_LOOP_PY=0` | `delegate_job_safe … --prompt "<text>"` (함수 인자) | 함수 안에서 `--prompt` 를 `.mam/prompt-shloop-XXXXXX` + `--prompt-file` 로 바꿔 래퍼 exec | `run_loop.sh:196-221`. `run_loop.sh` 의 잔존 `--prompt "…"` 11곳은 이 **함수** 호출이라 exec 을 거치지 않는다 |
|
||||
| `create_session.sh --submit-job` | 주입문에 과제 본문 포함 | 과제는 `<jobs>/<id>/brief.md` 에 쓰고 pane 에는 **경로만** | `create_session.sh:625-646` |
|
||||
|
||||
### 1-1. 래퍼의 파일 처리 (실측, `--dry-run --agent human`)
|
||||
|
||||
- 상대 `--prompt-file "my prompts/p 1.md"` 는 **호출자 cwd 기준**으로 절대화된다(`:191`, `cd "$WORKDIR"` 이전) — `--workdir` 가 다른 곳이어도 찾는다. 상대 `--registry-dir "reg dir"` 는 종전대로 `--workdir` 기준이다(기존 의미 유지). 공백 경로 모두 안전.
|
||||
- 등록된 `prompt` 는 **바이트 단위로 동일**(`'line1\nline2\n\n'`, 후행 개행 보존) — registry 가 파일을 직접 읽기 때문. 래퍼의 `$PROMPT` 변수(`$(<file)`)는 후행 개행을 잃지만 그것은 브리프 본문에만 쓰인다(H4).
|
||||
- 래퍼는 호출자의 파일을 **지우지 않는다**(소유권은 호출자). `--prompt` 와 `--prompt-file` 동시 → 거부, 없는 파일 → `cannot read --prompt-file <절대경로>` 거부.
|
||||
- 읽는 시점: register 는 래퍼의 첫 단계(`:207`)이고 래퍼는 잡이 끝날 때까지 블록하므로, 러너·lib.sh·셸 러너가 **래퍼 반환 후** 지우는 순서는 안전하다.
|
||||
|
||||
### 1-2. 임시 파일의 위치·권한·정리
|
||||
|
||||
| 생성자 | 위치 | 권한 | 정리 |
|
||||
|---|---|---|---|
|
||||
| 러너 | 제어 루트 `.mam/` (`self.mam_dir`) | `NamedTemporaryFile` → 0600 | `finally: os.unlink` — 성공·실패·**OSError(exec 실패)** 모두 (`:498-502`) |
|
||||
| lib.sh | `${WORKSPACE_ROOT}/.mam/` | `mktemp` → 0600 | 제출 뒤 `rm -f`; **쓰기 실패 시 `rm -f` 후 return 1** (`lib.sh:2042`) |
|
||||
| 셸 러너 | `$MAM_REAL_ROOT/.mam/` | `mktemp` → 0600 | 래퍼 반환 뒤 `rm -f`; 쓰기 실패 시 제거 + 원인 로그 + return 1 (`:201-205`) |
|
||||
| create | `<jobs>/<id>/brief.md` (잡 디렉터리, 보존 대상) | umask | 실패 시 잡 `error` + exit 1 (`:632-637`) |
|
||||
|
||||
### H1 (비차단이지만 실제 결함, 재현) — 러너의 쓰기 실패는 부분 파일을 남기고 예외를 던진다
|
||||
|
||||
`runner.py:472-477` 의 `with NamedTemporaryFile(...) as fh: fh.write(p_text)` 는 `try` **바깥**에 있다. 파일이 만들어진 뒤 `write` 가 실패하면(ENOSPC·EIO) `finally` 의 `unlink` 에 닿지 않는다. 검증 경로의 러너를 in-process 로 띄우고 `write` 가 `OSError(28)` 을 내도록 감싼 결과:
|
||||
|
||||
```
|
||||
RAISED: OSError [Errno 28] No space left on device ← delegate_job 밖으로 그대로 전파(트레이스백, 126 처리 아님)
|
||||
leftover prompt files in .mam: ['prompt-loop-…-2ussg8e6.md'] ← 부분 파일 잔존
|
||||
wrapper called: 0
|
||||
```
|
||||
|
||||
같은 부류를 PR 은 `lib.sh:2042`(`|| { rm -f "$f"; return 1; }`)과 `run_loop.sh:201-205` 에서 고쳤고 S9 로 고정했지만, 러너에는 대응이 없고 테스트(S4/S4b)도 `subprocess.run` 의 OSError 만 다룬다. 브리프 항목 1 의 "쓰기 실패 시 부분 파일이 남지 않는지"에 대해 러너는 **아니오**다.
|
||||
|
||||
→ 권고: `with` 블록을 `try` 안으로 옮기고 `except OSError` 에서 `unlink` 후 `CompletedProcess(cmd, 126, "", "cannot write the prompt file: …")` 를 돌려주면 exec 실패와 같은 "실패 제출" 경로로 합쳐진다(3–4줄). `self.mam_dir.mkdir` 실패도 같은 블록에서 잡힌다.
|
||||
|
||||
## 2. 실패 처리 (항목 2)
|
||||
|
||||
- **exec 실패(OSError → rc 126)**: `_run_cmd` 가 `CompletedProcess(cmd, 126, "", "cannot start the wrapper: … (prompt N bytes, wrapper …)")` 를 돌려주므로 첫 시도·busy 재시도 모두 같은 "실패 제출" 경로로 들어간다. 재시도 실패 시 job id 가 없는 경우에도 이제 `log_error` 를 남긴다(`runner.py:525`, 이전에는 무언). `failed_submit_jobs` 처리는 바뀌지 않았다(id 가 있으면 등록, 없으면 `None` 반환 → 호출자가 `return 1`). S4/S4b 가 E2BIG·EAGAIN 으로 고정.
|
||||
- **재심·중재 미등록 → rc 1**: `:1354-1357`, `:1376-1378`. 판정 잡이 등록조차 안 됐는데 "반론 기각"이나 "중재 없음"으로 흘러가던 것을 실패로 바꾼 것이라 맞다.
|
||||
- **순응(compliance) 잡 `wait_for_job` 실패 → rc 1** (`:1392-1395`): 의도된 강화로 판단한다. 근거는 **정규 수정 잡과의 대칭**이다 — 일반 correction 잡도 `if not correct_jid or not self.wait_for_job(correct_jid): return 1` (`:1323-1326`) 로 끝난다. 순응 잡은 "반론이 기각됐으니 그대로 고쳐라"는 수정 잡의 변형이므로, 시간 초과·에러로 닫힌 잡을 두고 다음 리뷰 라운드로 가면 **바뀌지 않은 트리를 다시 리뷰**하게 된다. 루프가 `wait_for_job` 실패를 통과시키는 자리는 이제 self-review(`:1187`)뿐이고 그것은 판정 리포트로 따로 걸러진다. S3c 가 세 경로(comply/adjudication/arbitration)의 미등록을 고정한다.
|
||||
|
||||
## 3. `MAM_DIFF_MAX_BYTES`/`MAM_DIFF_MAX_LINES` (항목 3)
|
||||
|
||||
- `diff_collect.sh:9-11` 주석이 "증거 크기의 상한이지 전달 상한이 아니다"를 명시하고, 상한값·잘림 마커·`runner.py` 의 l15 규칙(잘린 diff 면 diff-only 원격 리뷰어 거부, 로컬 진행)은 **변경되지 않았다**(runner 의 해당 블록은 diff 밖). S5 가 "옛 사각지대"(131,072 < diff < 200,000 바이트)의 diff 가 통째로 전달됨을 고정한다.
|
||||
- **H2 (비차단, 문서)**: loop SKILL.md 에는 이 두 변수도, 잘림 마커의 의미도 적혀 있지 않다(grep 0건). delegate-job SKILL.md 는 argv 상한만 말한다. "전달은 무제한, 증거는 200 KB/4,000 줄에서 잘리며 잘린 diff 는 diff-only 원격 리뷰어에게 증거가 못 된다"는 한 문단이 loop SKILL.md 에 있어야 항목 3 의 "일관 반영"이 완성된다.
|
||||
|
||||
## 4. `create_session.sh` (항목 4)
|
||||
|
||||
- 잡 등록은 종전대로 lib.sh 의 `delegate_submit_job`(파일 경유)이고, 그 뒤 `<jobs>/<id>/brief.md` 에 과제를 쓰고 pane 에는 `Task: read the complete task in <절대경로> and follow it.` 만 주입한다(`:625-646`). `$pub`(`delegate_publish_cmd` 의 `--expect-dispatch-token`·`--attempt-id`)와 `started` 기반 판정은 **손대지 않았다** — #23 계약 유지. S8 이 주입문에 brief 경로는 있고 과제 본문은 없음, `--expect-dispatch-token`·`--event started` 가 남아 있음을 단언한다.
|
||||
- 경로: `${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}` 를 절대화하므로 워크트리 creator(제어 루트 바인딩)도 자기 cwd 와 무관하게 읽을 수 있다. 래퍼가 만드는 `brief.md` 와 같은 자리다.
|
||||
- 실패 시 잡 `error` + `JOB_TERMINALIZED=1` + exit 1 → 기존 트랩이 세션을 되돌린다(초기화 실패와 같은 취급).
|
||||
|
||||
## 5. 새 테스트 22건 (항목 5)
|
||||
|
||||
- **실제 경로를 지나는가**: S1 은 실제 래퍼(`bash multi-agent-mux-delegate-job submit --dry-run`)와 실제 `registry.py` 로 140,000 바이트를 등록해 **바이트 단위 동일**을 단언한다. S2/S3/S3b/S3c 는 `python -m lib_py.loop` 를 하위 프로세스로 띄우고 가짜 래퍼가 받은 인자를 검사한다. S7/S7b/S9 는 `run_loop.sh`(MAM_LOOP_PY=0)를 실제로 돈다. S8 은 mock herdr 위에서 `create_session.sh` 를 실제로 돈다. mock 으로 우회하는 것은 S4/S4b(`subprocess.run` 을 OSError 로 대체 — 커널 대신 코드 경로를 검증하는 것이 목적)뿐이다.
|
||||
- **macOS**: `MAX_ARG_STRLEN` 이 없어 단일 인자 150 KB 가 통과하므로 S1b(음성 대조군)는 `linux_only` 로 **skip** 되고, S8 의 `strace` 검사는 Linux 에서만 붙는다. 즉 이 호스트에서 스위트는 **커널 상한이 아니라 코드 경로**(파일로 전달됐는가)를 검증한다 — 브리프의 우려 그대로이며, 설계상 옳다. 실제 상한 재현은 Linux CI 몫.
|
||||
- **격리**: `_env()` 가 `HOME`·`WORKSPACE_ROOT`·`DELEGATE_JOB_LOGS_DIR` 를 `tmp_path` 로, 브로커를 `127.0.0.1:9` 로 고정하고 `MAM_JOBS_DIR`/`MAM_REAL_ROOT` 를 제거한다. 실제 `.mam`·홈 접촉 없음(§6 실측).
|
||||
- **수정 없이는 실패하는가 — 확인함**: 검증 경로 사본에서 PR 이 바꾼 소스 8파일을 `1debc7d` 판으로 되돌리고 새 파일을 돌린 결과 **18 failed / 3 passed / 1 skipped(S1b)**. 통과 3건은 정정 전에도 성립하던 것(래퍼 실패 → rc 1 등)이다.
|
||||
|
||||
## 6. 검증 경로에서의 테스트 실행 (항목 5·6)
|
||||
|
||||
```
|
||||
tests/test_issue36_prompt_transport.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
|
||||
tests/test_loop_cli.py tests/test_issue21_loop_b.py tests/test_issue21_acceptance.py tests/test_issue23_24_startup_path.py
|
||||
tests/test_issue25_plan_reuse.py
|
||||
→ 1 failed, 237 passed, 1 skipped in 422.71s (0:07:02)
|
||||
```
|
||||
|
||||
- **skip 1**: S1b(`linux_only`) — 설계된 것(§5).
|
||||
- **failed 1: S9 — 코드 결함이 아니라 테스트의 macOS 이식성 문제(H5).** S9 는 `ulimit -f 1` 로 쓰기를 실제로 실패시킨 뒤 세 가지를 단언한다: rc≠0, stderr 에 `File too large`, 부분 파일 없음 + 원인 문구. 같은 하네스를 scratchpad 에서 직접 돌린 결과 **rc=1, 부분 파일 없음, `delegate_job_safe: cannot write the prompt file` 출력** — 즉 PR 이 고친 동작(제거 + 원인 표시)은 macOS 에서도 성립한다. 실패한 것은 두 번째 단언뿐이다: macOS 의 `/bin/bash` 3.2 는 `printf` 의 쓰기 실패에 대해 `printf: write error: File too large` 문자열을 내지 않는다(Linux bash 5 의 형식). `strerror(EFBIG)` 자체는 macOS 에서도 "File too large" 이므로 순수하게 bash 버전 차이다. S1b 처럼 `linux_only` 로 두거나, 그 단언을 빼고 원인 문구 단언만 남기면 된다.
|
||||
- 기존 계약 스위트(issue5 phase1 의 registry CLI 스냅샷·help 캡처, issue14, loop_cli, issue21 loop B·acceptance, issue23/24 S7*, issue25)는 **전부 통과** — `--prompt` 하위 호환과 온보딩 계약이 유지된다(항목 4·6).
|
||||
|
||||
### 격리·위생 실측
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
|
||||
| 실제 `.mam` 의 `prompt-*` 잔존 | **0** |
|
||||
| 검증 경로 `.mam` 의 `prompt-*` 잔존 | **0** (러너·셸 러너·lib.sh 의 정리가 실제 실행에서도 성립) |
|
||||
| 검증 경로 tracked 파일 | 변화 없음(HEAD `59be1c8`), 본 체크아웃 `main 1debc7d` |
|
||||
| 실행 시간 | 새 파일 22건은 34 s 안팎(되돌린 사본 기준 34.5 s); 전체 7 분은 issue14/18 계열의 벽시계 시험 몫 |
|
||||
|
||||
## 7. 문서 (항목 7)
|
||||
|
||||
- delegate-job SKILL.md `:44-48`: `--prompt-file` 의 의미와 argv 상한, "루프는 항상 파일을 쓴다" 명시 ✓. 래퍼 usage(`:112-121`)도 갱신 ✓.
|
||||
- loop SKILL.md `:267`: 파이프라인에서의 종료 코드(`pipefail`/`PIPESTATUS`) 안내 ✓. 그러나 `MAM_DIFF_MAX_*` 의 새 의미는 없다(H2).
|
||||
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`)라 자동 동기화된다 — 배포 사본 정합 문제 없음.
|
||||
|
||||
## 8. 병합 차단 여부 (항목 8)
|
||||
|
||||
버전 락스텝 bump 부재(`lib.sh:32` `4.7.2`, 오케스트레이터가 4.7.3 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 다만 H1 은 릴리스 커밋에 함께 넣거나 즉시 후속으로 닫기를 권한다 — 셸 쪽과 같은 부류이고 수정이 세 줄이다.
|
||||
|
||||
| # | 지적 | 성격 | 근거 |
|
||||
|---|---|---|---|
|
||||
| H1 | 러너의 프롬프트 파일 **쓰기** 실패 시 부분 파일 잔존 + 예외 전파(126 실패 제출로 합류하지 않음) — 재현 | 비차단(경계 상황), **후속 권고 1순위** | `runner.py:472-477` vs `:490-502`; §1 H1 |
|
||||
| H2 | `MAM_DIFF_MAX_*` 의 "증거 상한, 전달 무제한" 의미가 loop SKILL.md 에 없음 | 문서 | `diff_collect.sh:9-11` 에만 있음 |
|
||||
| H3 | 래퍼는 **빈** `--prompt-file` 을 받아들여 빈 프롬프트를 등록한다(실측) | 사소 | `multi-agent-mux-delegate-job:195` 의 `-n "$PROMPT" \|\| -n "$PROMPT_FILE"` |
|
||||
| H4 | 래퍼의 `$PROMPT="$(<file)"` 는 후행 개행을 잃는다 — registry 의 프롬프트(파일 그대로)와 브리프 본문이 그 점에서 다를 수 있음 | 사소 | `:193` |
|
||||
| H5 | S9 의 `File too large` 단언이 macOS bash 3.2 에서 실패(문자열 형식 차이); 실질 단언 2개는 통과 | 테스트 이식성 | §6 |
|
||||
|
||||
## 9. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 59be1c8… / a009c90…
|
||||
grep -rn -- '--prompt ' "$RP/.agents/skills" | grep -v '\.md:' # 래퍼 usage/기본값, cli 주석, run_loop.sh 의 함수 호출뿐
|
||||
# H1 재현: 러너 in-process, NamedTemporaryFile.write 가 OSError(28) → .mam/prompt-* 잔존 + 예외 전파 (§1 H1 의 스크립트)
|
||||
# S9 하네스 직접 실행(macOS): rc=1, 부분 파일 없음, "cannot write the prompt file" 출력, 단 "File too large" 문자열 없음
|
||||
# 되돌린 사본: 소스 8파일을 1debc7d 판으로 → tests/test_issue36_prompt_transport.py → 18 failed / 3 passed / 1 skipped
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "5bdb3a81",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-20T15:51:16Z"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
|
||||
- identity: HEAD `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||
- 범위: base `ff8b381`..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
|
||||
- 기준 문서: 이슈 본문과 댓글 전체(`issue40.txt`)를 끝까지 읽고 합의 사항과 대조했다.
|
||||
- 환경: macOS, Python 3.9.6.
|
||||
- 실제 에이전트 홈은 `ls` 와 `lsof -p <agy pid>`(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
|
||||
- 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.
|
||||
|
||||
## 결론
|
||||
|
||||
합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.
|
||||
|
||||
## 설계 확인 (합의 대비)
|
||||
|
||||
**어댑터는 사실과 판정만 제공한다.**
|
||||
- `id_lifecycle`: 기본값은 `POST_DISCOVERED`. claude·grok 만 `PRE_ASSIGNED` 이다(`base.py:378-381`, `claude.py:188`, `grok.py:18`).
|
||||
- `capture_active_session_id`(`base.py:383-401`): `discover()` 후보 가운데 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 `captured` 를 돌려준다. 그 밖에는 이유가 담긴 결과(`capture_result`)를 돌려준다. 읽기 전용이다.
|
||||
- 설정 추가나 리팩터링은 없다.
|
||||
|
||||
**agy 경로 (`agy.py`)**
|
||||
- `verify_artifact` discover 모드(`:97-106`): `last_conversations.json` 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
|
||||
- `capture_active_session_id`(`:129-152`): 후보는 `last_conversations.json[cwd]` 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(`_owner_check`, `:154-183`, lsof/ps). 증명하지 못하면 확정하지 않는다.
|
||||
- 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(`--conversation <id>`)는 모두 자기 `presence/<id>.lock` 을 열고 있다(`lsof -p`).
|
||||
- `_holder_pids`·`_process_parents` 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 `[]`, 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.
|
||||
|
||||
**reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다** (`reconcile.sh:778-811`).
|
||||
- 네 에이전트가 모두 `autopin.capture` 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 `*.db` 전체 glob 은 사라졌다.
|
||||
- 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
|
||||
- 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(`autopin.claimed_elsewhere`).
|
||||
- 지운 hermes·opencode·codex SQL 은 각 어댑터의 `discover()` 와 같은 범위(cwd·epoch·`parent_id IS NULL`)다. codex 는 `CODEX_HOME` 을 따른다(`codex.py:65`).
|
||||
|
||||
**수신 측 처리기 (`lib_py/agents/autopin.py`)**
|
||||
- 호출 지점:
|
||||
- 래퍼: `finalize_unfinished` 뒤 `completed` 일 때(`multi-agent-mux-delegate-job:454-458`). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
|
||||
- `create --onboard` 의 completed 분기(`create_session.sh:754-756`)
|
||||
- 루프 `wait_for_job` 직후의 서브프로세스(`runner.py:679, 690-705`, G9 경계를 지킴)
|
||||
- 공통 헬퍼 `delegate_autopin_completed`(`lib.sh:2470-2480`)는 `|| true` 라 잡 결과와 종료 코드를 바꾸지 않는다. `set -u` 에서 쓰는 변수(`AGENT_SESSIONS_YAML`, `_disp_tok`, `DELEGATE_JOB_TOKEN`, `task_reg_dir`)는 모두 정의돼 있다.
|
||||
- 중복 방지:
|
||||
- 키는 `(job_id, dispatch_token, attempt_id)` 이다. 잡 디렉터리의 `autopin.lock`(flock)과 `autopin.json` 마커를 쓴다. 마커는 원자 교체로 쓴다.
|
||||
- 쓰기는 `atomic_dump_yaml` 의 mutation 으로 한다. 이 모듈의 `_MUTATION` 을 `sys.modules[__name__]` 로 찾아 `python -m` 에서도 같은 객체를 쓴다. 쓸 것이 없으면 `SystemExit(0)` 으로 쓰지 않고 끝나며, `atomic_yaml.py:195` 의 finally 가 정리한다.
|
||||
- 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
|
||||
- 메타데이터: pin 은 `session_id_source=discovered`, `session_id_verified=true` 를 함께 기록한다. reconcile 의 `_pin_and_verify_resume` 도 같은 `pin_row` 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
|
||||
- publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(`test_call_sites_and_publisher_boundary`).
|
||||
- 잠금 안에서 뷰포트 확인과 resume `--dry-run` 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. `load_state_json` 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.
|
||||
|
||||
## 완료 조건별 테스트 (base 실패 / head 통과)
|
||||
|
||||
`tests/test_issue40_auto_pin.py`(27개)를 base 사본에 넣어 실행하면 **16 failed, 13 errors**(errors 는 base 에 없는 `agy._holder_pids` 를 패치하는 fixture 때문), **41 passed**(issue64 포함) 이다. head 에서는 issue40+issue64 **70 passed**.
|
||||
|
||||
| 조건 | 테스트 | base 결과 |
|
||||
|---|---|---|
|
||||
| 1 | `test_agy_verify_rejects_id_cached_for_another_workspace` | `True is False` |
|
||||
| 1 | `test_reconcile_does_not_pin_another_workspaces_agy_conversation` | `'b4000002…' is None` — 실제로 B 의 id 를 A 행에 기록함 |
|
||||
| 1 | `test_autopin_does_not_pin_…` | — |
|
||||
| 1 | issue64 `:407` 단언 | `is False` 로 바뀜 |
|
||||
| 2 | `test_completed_onboarding_pins_agy_without_daemon`(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) | — |
|
||||
| 2 | `test_completed_first_job_pins_opencode_codex_hermes`[3] | 실패 |
|
||||
| 2 | `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` | 실패 |
|
||||
| 3 | `test_wrapper_and_loop_recovery_pin_once`(capture 1회, YAML mtime 불변), `test_loop_recovery_pins_when_the_wrapper_did_not` | 실패 |
|
||||
| 3 | `test_ambiguous_candidates_leave_reason_without_pin`(행에 `autopin ambiguous (job amb): 2 candidates`) | 실패 |
|
||||
| 4 | `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing`, `test_stale_dispatch_token_pins_nothing` | — |
|
||||
|
||||
- reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(`_mock_agent`). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.
|
||||
|
||||
뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| 다른 cwd 거절 제거 | 1 failed |
|
||||
| 중복 마커 무시 | 2 failed |
|
||||
| 모호 판정 제거 | 1 failed |
|
||||
| agy 소유 확인 제거 | 4 failed(같은 cwd 형제 교차 pin 포함) |
|
||||
| 이미 id 가 있는 행 보호 제거 | 1 failed |
|
||||
| remote·human 제외 제거 | 2 failed |
|
||||
| 래퍼 호출 제거 | 1 failed(정적 검사만 잡음) |
|
||||
| **pre-assigned 보호 제거** | **실패 0** (아래 T1) |
|
||||
|
||||
## 관련 테스트 묶음
|
||||
|
||||
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad.
|
||||
- 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
|
||||
- 결과: **577 passed, 1 skipped, 3 failed**, 18분.
|
||||
- 실패 3개(`test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh]`, `test_w11_…`)는 환경 탓이다.
|
||||
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(`~/Library/Python/3.9/...`)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
|
||||
- base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
|
||||
- skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
|
||||
- 부작용 확인:
|
||||
- 실제 `~/.gemini/antigravity-cli/conversations`·`~/.hermes`·`~/.local/share/opencode` 목록은 전후가 같았다.
|
||||
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||
- #55 가드 오탐은 없었다.
|
||||
- 린트:
|
||||
- `git diff --check` 는 깨끗하다.
|
||||
- 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 `/bin/bash -n`(3.2)을 통과한다.
|
||||
- 바뀐 Python 은 3.9 로 파싱된다. `/usr/bin/python3` 로 `lib_py.agents.autopin`·`lib_py.loop.runner` 를 import 할 수 있다.
|
||||
|
||||
## 비차단
|
||||
|
||||
- **T1. pre-assigned 보호(`autopin.capture` 의 `id_lifecycle != POST_DISCOVERED`)는 테스트로 고정되지 않았다.**
|
||||
- 테스트의 claude·grok 행은 이미 id 가 있어서 `already-set` 에서 먼저 끝난다.
|
||||
- id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude `discover()` 로 남의 transcript 를 pin 할 수 있다.
|
||||
- 권고: `claude_session_id_own: None` 인 claude 행에서 `pre-assigned` 가 나오는지 확인하는 한 줄을 추가한다.
|
||||
- **T2.** 래퍼 진입점은 정적 검사(`test_call_sites_…`)로만 고정됐다. 헬퍼(`lib.sh`)와 CLI, create, 루프는 실행으로 검증됐다.
|
||||
- **R1. agy 의 pin 은 이제 세 가지에 기댄다**(reconcile drift C 도 같다):
|
||||
- (a) lsof·ps 가 있어야 한다
|
||||
- (b) 행에 pane pid 가 있어야 한다
|
||||
- (c) agy 가 presence lock 을 열고 있어야 한다
|
||||
- (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
|
||||
- lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 `autopin unverifiable-owner … lsof is unavailable` 을 남기지만, reconcile 은 아무것도 남기지 않는다.
|
||||
- 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
|
||||
- **R2.** 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
|
||||
- **범위 밖(보고만).** stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261008T234013Z-4f5d21",
|
||||
"base_sha": "97c2946ee0e11eabcecafe6407755c33c0289218",
|
||||
"head_sha": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
|
||||
"tree_sha": "741c19ba4c17601768b06684d5843d218a72beb1",
|
||||
"review_commit": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "616578e7",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-09T00:45:03Z"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# 리뷰 리포트 — Job 616578e7 (loop-lock: 테스트가 체크아웃의 루프 잠금을 잡지 않게, 97c2946..a93c9bf)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261008T234013Z-4f5d21 / base 97c2946 / head a93c9bf / tree 741c19b — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 1개(+55줄), 테스트 3개 파일: test_o3_scoped_guard.py 의 b13 두 곳에 `MAM_LOOP_MARKER=tmp_path/...`, 새 tests/test_loop_marker_isolation.py(L1), test_issue17_workspace_root_isolation.py W1 에 `.mam` 부재 단언 1줄. 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
할 일 1~4 가 요구대로이고, 내 독립 조사(grep + 사본 트리 실행)로도 누락된 실행 지점이 없다. 잠금 동작을 시험하는 테스트들의 marker 경로는 바뀌지 않았다. 차단 결함 없음.
|
||||
|
||||
## 1. 조사(1) — 직접 grep 한 결과 (run_loop.sh 실행 지점 전수)
|
||||
run_loop.sh 는 :15 `MAM_REAL_ROOT=${MAM_REAL_ROOT:-$REPO_ROOT}`, :110 `--help` 와 :66-141 인자 오류는 :181 의 marker 결정·:248 `mam_acquire_loop_lock` **이전**에 종료, loop_lock.sh:66 이 부모 디렉터리를 mkdir -p. Python `LoopRunner` 는 잠금을 잡지 않는다(runner.py:1224-1226 은 release 뿐, acquire 는 run_loop.sh:248 에만 있음).
|
||||
|
||||
| 실행 지점 | 분류 | 근거 |
|
||||
|---|---|---|
|
||||
| test_o3_scoped_guard.py:388, :472 (b13 두 테스트) | **대상 → 수정됨** | 체크아웃 run_loop.sh, `--creator nonexistent-agent --task …`, cwd=REPO_ROOT; 이제 `MAM_LOOP_MARKER=tmp_path/loop-guard-active`(:390, :470) |
|
||||
| test_issue5_phase3_loop.py:166, :184 | 이미 지정 | :163 `MAM_LOOP_MARKER=tmp_path/loop-marker` |
|
||||
| test_loop_cli.py:52 (`_run_loop`) | 이미 지정 | env_extra 에 `MAM_LOOP_MARKER=tmp_path/loop-guard` |
|
||||
| test_o2_race_free_lock.py:203 (o2_12) | 이미 지정(잠금 경합 자체를 시험) | :202 `MAM_LOOP_MARKER=marker(tmp_path)` — 의도 유지 |
|
||||
| test_issue25_plan_reuse.py:367 (p7b) | 이미 지정 | `MAM_LOOP_MARKER=tmp_path/loop.marker` |
|
||||
| test_issue21_loop_b.py:239, test_issue21_worktree_prereqs.py:405 | 이미 지정 | WORKSPACE_ROOT=tmp + `MAM_LOOP_MARKER` |
|
||||
| test_issue21_acceptance.py:298, test_issue36_prompt_transport.py:540, test_b7_diff_untracked.py:189, test_tier4_e2e.py:285 | 복사본 트리의 run_loop.sh(`main`/`ctl`/`base`/`tmp_path`) | 체크아웃이 아님(acceptance 는 marker 도 지정) |
|
||||
| test_issue5_phase4_runtime.py:132-146 (p4_03), test_issue25:355, test_issue5_phase3:151, test_o1_rebuttal.py:72 | `--help` → :110 에서 종료 | 잠금 전 |
|
||||
| test_o1_rebuttal.py:76 (`--max-rebut abc`) | 인자 오류 → 잠금 전 종료 | :66-98 |
|
||||
| test_o3 Z-7/Z-14(:137, :155), test_deploy_freshness:473, test_issue5_phase0_contracts:481, test_o1:24/:166 | 소스 텍스트 읽기만 | 실행 없음 |
|
||||
| LoopRunner in-process(issue18, issue21_loop_b, issue25, issue30, issue33, issue34, issue36, issue6, phase4 등) | 잠금 미획득 | 위 runner.py 근거; 해당 없음 |
|
||||
|
||||
경험적 확인(W1 방식): HEAD 트리 사본에서 `WORKSPACE_ROOT=<사본>` 으로 브리프 후보 15개 모듈을 **파일 전체** 단위로 실행하고 매번 `.mam` 확인 — **15개 모두 clean**(b7 20, deploy_freshness 13+18 skip, issue21_acceptance 10, issue21_loop_b 34, worktree_prereqs 35, issue25 17, issue36 23+1 skip, phase0 41, phase3 12, phase4 5, loop_cli 9, o1 10, o2 22, o3 32, tier4_e2e 5 — 모두 passed). 놓친 실행 지점 없음.
|
||||
|
||||
## 2. 할 일 2·3 — 수정 방식과 회귀 테스트
|
||||
- 대상이 한 모듈의 두 테스트뿐이라 conftest fixture 대신 각 호출에 env 지정 — 브리프의 "검토" 요건에 맞는 최소 변경. o2 의 잠금 경합·o3 의 marker 검사(mam_sandbox 아래 marker)는 손대지 않았다.
|
||||
- L1(test_loop_marker_isolation.py:36-50)은 W1 과 같은 방법(체크아웃 복사 → 7개 노드 실행 → `.mam` 부재)이며 NODES 는 위 표의 "잠금까지 가는" 7개와 정확히 일치한다. W1 에도 `.mam` 디렉터리 자체의 부재 단언이 추가돼(:71) 잠금 디렉터리 생성이 두 테스트에서 잡힌다.
|
||||
- **수정 전 코드에서 실패 확인**: L1 자체는 `git ls-files` 가 필요해 `git archive` 사본에서는 못 돌리므로, L1 의 방법을 base 트리 사본 안에서 직접 재현했다 — 7개 노드를 `WORKSPACE_ROOT=<base 사본>` 으로 실행(7 passed) → 사본에 **`.mam/` 생성**(L1 의 마지막 단언이 실패하는 조건). 노드별로는 **b13 두 테스트만** 생성하고 나머지 5개는 clean — 테스트 머리말의 "The first two took the checkout's lock at 97c2946" 와 일치. HEAD 에서는 (a)(b) 모두에서 L1·W1 통과.
|
||||
|
||||
## 3. 할 일 4 — 실행 (검증 경로 워크트리, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| (a) WORKSPACE_ROOT 없이: test_issue17_workspace_root_isolation, test_loop_marker_isolation, test_o3_scoped_guard, test_o2_race_free_lock | **57 passed / 0 failed** (45 s); 실행 뒤 워크트리에 `.mam/` **없음** |
|
||||
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 같은 4개 파일 | **57 passed / 0 failed** (45 s); 센티널 **비어 있음**; 워크트리에 `.mam/` 없음 |
|
||||
- 본 체크아웃의 실제 잠금 `.mam/loop-guard-active` mtime 전후 동일(1791502813) — 이 리뷰 중 본 체크아웃에서는 아무것도 실행하지 않았다. 실제 .mam/jobs 항목 수 불변. 브로커 bind 제약 없음.
|
||||
- 이식성: Python 테스트 코드만(3.9 문법). L1 은 nested pytest + `git ls-files`, 이 Mac 에서 통과.
|
||||
|
||||
## 4. 비차단
|
||||
- L1·W1 둘 다 노드 목록을 수동 유지한다(이전 리뷰의 같은 지적). run_loop.sh 를 잠금까지 실행하는 테스트를 새로 추가하면 L1 NODES 에도 넣어야 한다.
|
||||
- L1 은 `_copy_checkout` 을 W1 모듈에서 import 하므로 두 테스트 파일이 결합돼 있다 — 작동에는 문제없고, 공용 헬퍼를 conftest 로 옮길지는 취향 문제.
|
||||
- 새 관찰 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261013T001819Z-131fa6",
|
||||
"base_sha": "8b0b4a07dc76a4915895bdde6170dc8eed9b5f74",
|
||||
"head_sha": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
|
||||
"tree_sha": "7905896f005159d596e3872d0ee2c3c12061f09a",
|
||||
"review_commit": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "63919c5d",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-13T00:57:46Z"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# 테스트의 실제 herdr 서버 누출 수정 리뷰 (job 63919c5d)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261013T001819Z-131fa6 / base 8b0b4a07 .. head 325b0560 (커밋 2개: 525df2b 수정, 325b056 s11 기준 이동 / 4 파일) / tree 7905896f
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T001819Z-131fa6/1` — HEAD·tree 가 identity 와 일치, `git status --porcelain` 0 줄(시작·종료 시), `.mam` 디렉터리도 생기지 않음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
|
||||
- 실행 안전: 내 pane 은 `HERDR_SOCKET_PATH`·`HERDR_PANE_ID`·`HERDR_TAB_ID`·`HERDR_WORKSPACE_ID`·`HERDR_SESSION_NAME`·`HERDR_SESSION`·`HERDR_CONFIG_PATH`·`HERDR_BIN_PATH`·`HERDR_ENV` 를 물려받고 있어, 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만)로 돌려 브리프의 `-u` 목록을 포함한 전부를 비웠다. basetemp 는 세션 scratchpad 아래(경로에 "shim" 없음). 실제 바이너리를 부른 것은 일회용 HOME 에서의 `status server`(읽기 전용, 서버 미기동)뿐이다. Python 은 본 체크아웃 `.venv`(3.9.6, paho 2.1.0). `.mam.env` 는 읽지 않았다.
|
||||
|
||||
## 1. 원인 판정이 맞는가
|
||||
|
||||
**원인 1 (조상 `*-shim` 이 mock 을 가림).** base 의 `_is_shim_path` 는 `*-shim/*` 를 `"/$1/"` 전체에 대해 매칭해 조상 디렉터리까지 걸렀고(`lib.sh` base), 생성 shim 의 `_resolve_real_herdr` 도 `[[ "$dir" != *"-shim"* ]]` 로 PATH 항목 문자열 어디든 걸렀다. basetemp `pytest-pr56-shim` 아래 mock `bin/` 은 둘 다에서 건너뛰어졌고 다음 후보인 `/opt/homebrew/bin/herdr` 가 "real" 이 된다. 수정 전 lib.sh 로 되돌린 scratch 복사본에서 L1 이 정확히 그 상태로 실패했다: `_resolve_real_herdr_path` 가 `…/pytest-x-shim/case0/bin/herdr` 대신 다음 PATH 항목의 디코이 `…/decoy/bin/herdr` 를 골랐다(`assert '/…/decoy/bin/herdr' == '/…/case0/bin/herdr'`). 판정 맞음.
|
||||
|
||||
**원인 2 (pane 의 `HERDR_SOCKET_PATH`).** 실제 herdr 0.9.3 바이너리를 일회용 HOME 에서 `status server` 로 읽기만 해 확인했다:
|
||||
|
||||
| 환경 | 바이너리가 가리킨 소켓 |
|
||||
|---|---|
|
||||
| `HERDR_SOCKET_PATH=/nonexistent/s.sock` + `--session t-zz` | `/nonexistent/s.sock` |
|
||||
| `HERDR_SOCKET_PATH=/nonexistent/s.sock` + `HERDR_SESSION=t-zz` | `/nonexistent/s.sock` |
|
||||
| `HERDR_SESSION=t-zz` 만 | `$HOME/.config/herdr/sessions/t-zz/herdr.sock` (가짜 HOME 아래) |
|
||||
| `HERDR_SOCKET_PATH` 만 | `/nonexistent/s.sock` |
|
||||
|
||||
즉 `HERDR_SOCKET_PATH` 는 **`--session` 보다도** 우선한다. shim 이 항상 `--session <샌드박스 서버>` 를 붙여도 이 변수가 남아 있으면 실제 서버로 간다 — 원인 1 로 실제 바이너리가 선택되는 순간 샌드박스 HOME 은 아무 보호가 되지 못했다는 오케스트레이터의 실측과 일치한다. 두 원인은 독립적이고 둘 다 막아야 한다(1 만 고치면 pane 변수 누출 시 다른 경로의 실제 호출이 여전히 실제 서버로, 2 만 고치면 숨은 mock 대신 실제 바이너리가 가짜 HOME 에 서버를 띄움). 판정 맞음.
|
||||
|
||||
## 2. 수정 내용 검토
|
||||
|
||||
- `lib.sh:113-124 _is_shim_path`: 첫 `case` 에서 `*-shim/*` 만 빠지고 `.mam/shim`·`$_HERDR_SHIM_DIR_PATTERN`(`/multi-agent-herdr-shim/`)·`$_HERDR_SKILLS_BIN_PATTERN`(`/.agents/skills/.bin`) 은 그대로. `*-shim` 은 `"${1%/}"` 즉 인자 자신의 끝에만 적용. `lib.sh:130-136 _resolve_real_herdr_path` 는 PATH 항목(`_is_shim_path "$dir"`)과 후보 파일의 **canonical dirname** 둘 다 검사한다 — L1 의 mock 은 `tmp/bin/herdr` 로의 symlink 라 canonical dirname 은 `tmp/bin` 이고, 선택 결과는 PATH 항목 쪽 경로(`$dir/herdr`)다. 기존 래퍼 디렉터리 제외 여부: `.mam/shim` → 첫 case(부분 문자열, 조상 포함) 유지; `multi-agent-herdr-shim` → 패턴 상수 유지(`"/…/multi-agent-herdr-shim/…/"`); `.agents/skills/.bin` → 패턴 유지(이 저장소에는 그 디렉터리가 없다); 테스트의 `herdr-shim`(L1b)·`py-shim`·`ps-shim`(`test_issue17_item4_decisions.py:208`, `test_issue21_acceptance.py:283`, `test_issue30_stall_watch_stop.py:95`) 은 디렉터리 자신의 이름이 `*-shim` 이라 계속 제외.
|
||||
- 생성 shim(`lib.sh:173-178`): `[[ "${dir%/}" != *-shim ]]`. `.mam/shim` 부분 문자열 검사는 그대로.
|
||||
- `tests/conftest.py:293-298 mam_sandbox`: `HERDR_SOCKET_PATH`·`HERDR_PANE_ID`·`HERDR_TAB_ID` 제거. 기존의 `HERDR_SESSION_NAME`·`HERDR_SERVER_NAME`·`HERDR_WORKSPACE_ID`·`HERDR_CONFIG_PATH`·`XDG_CONFIG_HOME` 제거와 합쳐 pane 이 내보내는 라우팅 변수 중 실제 서버에 닿을 수 있는 것은 모두 빠진다(아래 비차단 메모의 `HERDR_SESSION` 참조).
|
||||
- bash 3.2: `${1%/}`·`${dir%/}`·`case` 뿐, `bash -n` 통과. Python 3.9 컴파일 통과.
|
||||
- s11 기준 커밋 525df2b: 생성 shim heredoc 이 바뀌었으므로 기준을 그 커밋으로 옮긴 것은 #45 때와 같은 방식이고, 검증 경로에서 s11 PASSED. 525df2b 는 이 브랜치의 첫 커밋으로 검증 경로에 존재한다.
|
||||
|
||||
## 3. 충분한가 — 다른 경로로 실제 서버에 닿는 테스트
|
||||
- `mock_herdr`(`conftest.py:310`)·`mock_agents`(`:1412`) 는 `mam_sandbox` 에 의존하므로 mock 을 PATH 에 두는 테스트는 모두 샌드박스 환경(라우팅 변수 제거, HOME 교체)이다.
|
||||
- `mam_sandbox` 없이 herdr 를 흉내 내는 파일은 `test_issue14_delivery_ack.py`·`test_issue18_liveness_contract.py` 둘(bash 함수 `_sks_herdr` 대체, HOME=tmp). 이 둘이 herdr **바이너리**를 한 번이라도 부르는지 보려고 호출을 기록하는 디코이 `herdr` 를 PATH 맨 앞에 두고 두 파일을 돌렸다: **43 passed, 디코이 호출 0 건**(로그 파일이 생기지 않음). 즉 이 둘은 라우팅 변수가 남아도 바이너리를 실행하지 않는다. (이 실행의 `1 error` 는 세션 teardown 의 #55 agent-home 가드가 `~/.gemini/antigravity-cli/conversations/f9bf3d55-….db-shm/.db-wal` 을 잡은 것으로, 브리프가 예고한 다른 워크스페이스의 실행 중 agy 대화 WAL/SHM 이다. 테스트 산출물이 아니므로 판정에 쓰지 않고 보고만 한다.)
|
||||
- `test_issue6_fail_closed.py`·`test_issue6_turn_handshake.py` 는 `mock_herdr` 를 쓰므로 샌드박스다.
|
||||
- 실제 바이너리가 아는 라우팅 변수는 `HERDR_SOCKET_PATH`(최우선)와 `HERDR_SESSION`(HOME 상대 경로)뿐임을 위 표로 확인했다. `HERDR_SESSION_NAME` 은 바이너리가 모르고 shim 만 `--session` 으로 바꾼다(어제 리뷰에서 확인). 따라서 `HERDR_SOCKET_PATH` 제거 + 가짜 HOME 이면 shim 경로든 직접 호출이든 실제 서버에는 닿지 않는다.
|
||||
|
||||
## 4. 테스트 결과
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| head: `test_herdr_real_server_leak.py` + `test_issue23_24_startup_path.py` + `test_herdr_shim_contract.py` + `test_issue17_workspace_root_isolation.py` + `test_issue9_server_env.py` + `test_issue45_herdr_config.py` | **130 passed, 34 skipped**(skip 은 모두 `needs tomllib (Python 3.11+)` 류의 알려진 3.9 skip). L1·L1b·L2·s11 은 모두 PASSED 로 실행됨 |
|
||||
| 수정 전 lib.sh(8b0b4a0) + 새 테스트 | **L1 실패**(디코이 선택), **L1b 통과**(자기 이름 `*-shim` 은 전후 모두 제외) |
|
||||
| 수정 전 conftest(8b0b4a0) + 새 테스트 | **L2 실패**: `['HERDR_PANE_ID', 'HERDR_SOCKET_PATH', 'HERDR_TAB_ID']` 가 남음 |
|
||||
| 디코이 sweep(`test_issue14`, `test_issue18`) | 43 passed, 디코이 호출 0 |
|
||||
|
||||
수정 전 재현에는 실제 herdr 가 실행될 수 있는 구성을 쓰지 않았다(테스트 설계대로 디코이가 PATH 에서 실제 바이너리보다 앞서고, `env -i` 로 소켓 변수도 없음).
|
||||
|
||||
## 5. 공유 제어 평면에 주는 영향
|
||||
- 생성 shim 은 lib.sh 를 source 하는 모든 실행에서 `<워크스페이스>/.mam/shim/herdr` 로 다시 쓰이므로, 머지 뒤 실제 제어 평면의 shim 도 두 줄이 바뀐다. 바뀌는 것은 "조상 이름에 `-shim` 이 든 PATH 항목을 더 이상 건너뛰지 않음" 뿐이고, `.mam/shim` 과 자기 이름이 `*-shim` 인 디렉터리는 계속 건너뛴다. 내 pane 의 PATH 에서 `-shim` 을 포함하는 항목은 `~/.cmuxterm/cmux-cli-shims/<uuid>` 하나인데(`herdr` 없음, `amp claude codex hermes pi` 만) 전후 어느 규칙에도 해당하지 않아 영향 없다. 본 체크아웃의 `.mam/shim/herdr` 는 이 리뷰 중 건드리지 않았다(아직 base 내용).
|
||||
- 비차단 메모 1: README(`README.md:102`, `README.ko.md:84`)가 설명하는 레거시 레이아웃 `/tmp/multi-agent-herdr-shim/<server>/herdr` 는 디렉터리 자신의 이름이 서버명이라, 생성 shim 의 새 규칙으로는 제외되지 않는다(lib.sh 의 `_is_shim_path` 는 패턴 상수로 계속 제외). 현재 코드는 그 레이아웃을 만들지 않고 이 호스트에도 없으므로 동작 영향은 없지만, 생성 shim 에도 `*"/multi-agent-herdr-shim/"*` 를 두거나 README 를 현재 레이아웃(`.mam/shim`)으로 고치면 깔끔하다.
|
||||
- 비차단 메모 2: `mam_sandbox` 는 `HERDR_SESSION`(바이너리가 아는 변수, 내 pane 도 내보냄)을 지우지 않는다. 가짜 HOME 아래 경로로 풀리므로 실제 서버 누출은 아니지만, `lib.sh:1425 _herdr_caller_server` 와 `pane_lock.caller_server` 가 이 값을 "호출자 서버" 로 읽어 pane 안/밖에서 테스트 분기가 달라질 수 있다. 결정성을 위해 함께 지우는 것을 권한다(이 PR 의 범위 밖).
|
||||
|
||||
## 6. 잔류물
|
||||
- scratch 복사본·basetemp·디코이·로그는 모두 세션 scratchpad 아래. 일회용 HOME `/tmp/j639-h` 제거(서버를 띄운 적 없음). 실제 `.mam`·`.mam.env`·모니터·실제 서버(multi-agent-mux, default)·`~/.config/herdr`·에이전트 홈은 건드리지 않았다.
|
||||
|
||||
## 결론
|
||||
두 원인 판정은 코드와 실측(특히 `HERDR_SOCKET_PATH` 가 `--session` 보다 우선)으로 확인되고, 수정은 각각을 막으며 회귀 테스트는 수정 전 코드에서 정확히 그 이유로 실패한다. 기존 래퍼 디렉터리 제외는 유지되고, mock 없이 돌아가는 두 테스트 파일은 herdr 바이너리를 아예 부르지 않는다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,111 @@
|
||||
# 🔍 Review Report — PR #43 `fix/issue-26` 리뷰 (job `645adb2a`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
|
||||
- **대상**: PR #43 @ `b58edac`, base `main e1eb22b` (v4.8.0). 6커밋, 17파일 `+1590/−94`, `git diff --check` 클린.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T131705Z-a60092/1`. **identity 확인**: `HEAD = b58edace…4893`, `HEAD^{tree} = 14b670fb…6ffa` — 브리프와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main e1eb22b`) 모두 수정·커밋·checkout 없음. 실제 `.mam.env` 는 **값을 출력하지 않고** 형태만 셌다(`$`·`export`·인라인 `#`·따옴표 줄 수 = 모두 0). 파서 실험은 scratchpad 의 합성 파일로만. 테스트는 브리프대로 `.mam.env` 를 export 하지 않고(`MQTT_*` 미설정 확인) 돌렸다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
- **참고(이 잡의 liveness)**: 이 잡의 `started` 발행 때 브로커 `100.69.147.175:1883` 이 연결을 거부했다(`Connection refused`, 지금도 포트 닫힘). `publish_event` 는 rc 2 였지만 레코드는 `running`·`events.log` 에 `started`(토큰 포함)가 기록됐고 heartbeat 타이머도 떠 있다 — #18 계약상 로컬 증거로 충분하다. 브로커 쪽은 오케스트레이터 확인이 필요하다(아래 판정과 무관).
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**병합을 막을 결함은 없다.** 두 로더는 같은 입력에서 같은 결과를 내고(합성 파일 15줄 실측), 래퍼의 eval 경로는 어떤 값도 셸 해석하지 않으며(명령 치환 주입 실험: 실행 0), bash `mam_int` 와 Python `_positive_int` 는 15가지 입력에서 결과가 같다. 로컬 브로커로 옮긴 시험들은 skip 을 fail 로 바꾸고 양쪽 로그·감사 기록까지 단언해 **오히려 강해졌다**. 비차단 관찰 5건(R1–R5).
|
||||
|
||||
## 1. 파서 규칙 (검토 항목 1)
|
||||
|
||||
`envfile.py:45-73` `parse`, `mqtt_common.py:98-101` 은 `envfile.apply` 에 위임 — Python 쪽 로더는 **하나**다. 래퍼는 같은 파일을 `--shell` 로 실행한다. 같은 합성 파일을 두 경로에 넣은 결과:
|
||||
|
||||
| 입력 줄 | Python 로더 | 래퍼 eval 후 |
|
||||
|---|---|---|
|
||||
| `BOMKEY=1` (UTF-8 BOM) | 건너뜀(invalid key, 1행 경고) | 미설정 |
|
||||
| `A = spaced \r` (CRLF) | `'spaced'` | `spaced` |
|
||||
| `B="q's"` | `"q's"` | 동일 |
|
||||
| `C='x''` | `"x'"`(바깥 한 쌍만) | 동일 |
|
||||
| `D=a=b=c` | `'a=b=c'` | 동일 |
|
||||
| `E=` | `''` | `''` |
|
||||
| `export F=exp` | 건너뜀(invalid key) | 미설정 |
|
||||
| `G=val # trailing` | `'val # trailing'` | 동일 |
|
||||
| `H=$(touch …/PWNED) \`id\` ${HOME}` | 글자 그대로 | 글자 그대로, **PWNED 파일 생성 안 됨** |
|
||||
| `9BAD=x` / `noequals` | 건너뜀 + 경고 | 미설정 |
|
||||
| `IFS=x` / `BASH_ENV=x` | 건너뜀(reserved) | 미설정 |
|
||||
| `I="unbalanced` | `'"unbalanced'` | 동일 |
|
||||
| `J= "inner"` | `'inner'` | 동일 |
|
||||
|
||||
**두 경로가 완전히 일치한다.** 경고에는 값이 찍히지 않는다(줄 번호·사유만). 이전 Python 로더(`strip('"').strip("'")`)는 따옴표를 여러 겹 벗기고 키 검사가 없었는데, 이제 한 쌍만 벗기고 형식 오류를 경고한다 — 문서화된 규칙과 맞다.
|
||||
|
||||
**R1 (비차단, 문서)**: `.mam.env.example` 은 `$HOME/...` 예시를 `/home/you/...` 로 바꿔 "셸 해석 없음" 과 **일관**되게 했지만, 머리말에 규칙 자체(셸 확장 없음, 인라인 `#` 은 값의 일부, `export` 접두어 불가, BOM 저장 시 첫 줄 무시, **이미 있는 환경변수가 우선**)를 적지 않았다. 특히 래퍼 사용자에게는 세 가지가 **동작 변경**이다 — 전에는 bash `source` 가 `export KEY=`·`$HOME` 확장·인라인 주석을 받아들였다. 실제 `.mam.env` 에는 해당 줄이 없음을 확인했다(형태만 셈).
|
||||
|
||||
## 2. 래퍼의 eval 경로 (검토 항목 2)
|
||||
|
||||
`multi-agent-mux-delegate-job:41-46`: `python -I envfile.py --shell` 의 출력을 `eval`. 출력은 `envfile.py:97-98` 의 `print(f"export {key}={shlex.quote(val)}")` 뿐이고, `key` 는 `_KEY.fullmatch` 를 통과한 것만, `val` 은 `splitlines` 로 개행이 없고 `shlex.quote` 로 감싸진다 — 주입 실험에서 `$()`·백틱·`${}` 모두 글자 그대로 남았다.
|
||||
- 래퍼가 그 전에 세운 비-export 변수는 `SCRIPT_DIR`·`REPO_ROOT`·`TARGET_ENV` 셋뿐이고, 셋 다 환경으로 넘겨 파일이 덮어쓸 수 없다(`:42`). env-wins 실측: `D=fromenv` 가 파일의 `D=a=b=c` 를 이긴다.
|
||||
- 실패 경로: 파일이 **없으면** rc 0·출력 없음, **읽을 수 없으면** stderr `WARNING: … cannot read (PermissionError)` 후 rc 0 — 래퍼는 설정 없이 계속한다. 래퍼의 `|| { echo "ERROR: cannot read $TARGET_ENV"; exit 1; }`(`:44`)는 **Python 실행 자체가 실패할 때**(인터프리터 없음 등)만 걸려, 메시지와 실제 원인이 어긋난다(R2, 사소).
|
||||
- `-I`: `PYTHONPATH`·사용자 site 를 무시하지만 `envfile.py` 는 표준 라이브러리만 쓰고 파일 경로로 실행되므로 문제없다. 오히려 `.mam.env` 를 읽기 전 환경의 `PYTHON*` 이 파서에 끼어들 여지를 없앤다.
|
||||
|
||||
## 3. 우선순위 변경의 영향 (검토 항목 3)
|
||||
|
||||
전에는 래퍼의 `source` 가 환경을 덮어썼고 Python 로더는 환경을 지켰다 — 같은 파일이 두 경로에서 다른 결과를 냈다. 이제 둘 다 env-wins.
|
||||
- **루프 러너**가 래퍼에 넘기는 env(`DELEGATE_JOB_PYTHON`, `--registry-dir` 대신 쓰는 `MAM_JOBS_DIR` 등): 전에는 파일 값에 덮일 수 있었다 → 이제 러너 값이 이긴다. **개선**.
|
||||
- **워크트리 creator**: create/resume 이 넘기는 절대경로 바인딩(`AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·…)이 이제 파일 값에 덮이지 않는다 — #21 의 의도와 맞는 **개선**.
|
||||
- **원격 러너**(`remote.py`)는 원래 Python 로더라 변화 없음. **테스트**는 `conftest` 가 `MAM_ENV_FILE` 을 없는 파일로 고정해 무관.
|
||||
- 동작이 나빠지는 경로: 셸에 **오래된 `MQTT_*` 를 export 해 둔 채** 파일만 고친 사용자는 이제 파일 값이 적용되지 않는다. 이 부분이 R1 의 문서 누락과 겹친다.
|
||||
|
||||
## 4. 정수 정규화 (검토 항목 4)
|
||||
|
||||
bash `mam_int`(`lib.sh:2345-2356`)와 Python `_positive_int`(`runner.py:71-82`)를 같은 15개 입력에 돌린 결과 **전부 일치**: `' 30 '→30`, `0700→700`(8진 아님), `0900→900`, `0→거부(min 1)`, `-5`·`+5`·`1e3`·`30s`·전각 `30`·빈 값 → 거부, `999999999→수용`, `1000000000→거부`(9자리 상한), `0000000000000000001→1`(앞 0 제거 후 길이 판정).
|
||||
- 래퍼: 환경 노브는 `mam_int_knob`(잘못되면 경고 + 기본값, `:154-156`), 플래그는 `parse_opts` 끝의 루프(잘못되면 `ERROR … not a positive decimal integer` rc 1, `:196-200`). `MAM_INJECT_ACK_GRACE_S` 는 min 0(0 = 대기 없음 허용, 의미상 맞다), `ACK_TIMEOUT`·heartbeat·budget 은 min 1. lib.sh `SKS_*` 대기 노브는 min 0(대기 생략 허용), `delegate_wait_started` 도 0 허용(`:2379`).
|
||||
- 빠진 노브: `$(( ))` 안에 환경 기본값을 직접 쓰는 자리는 남아 있지 않다(B5 정적 검사가 고정). `[ -ge ]`/`[ -le ]`/`sleep` 에만 쓰이는 노브(`MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`·`SKS_IDLE_RETRIES`·…)는 `test` 의 비교가 원래 10진수라 8진 문제가 없고, PR 이 표로 분류해 "모든 정수 노브가 분류됨" 을 시험한다(`test_b5_every_integer_env_default_is_classified`). 그 노브들에 숫자가 아닌 값이 오면 `[` 가 오류(rc 2)를 내는 기존 동작은 남는다(R3, 범위 밖).
|
||||
|
||||
## 5. 로컬 브로커와 이전된 시험 (검토 항목 5)
|
||||
|
||||
`tests/_local_mqtt.py` 는 MQTT 3.1.1 의 필요한 부분을 클라이언트가 기대하는 순서로 처리한다: CONNECT(레벨 3/4만, 그 외 CONNACK rc 1)→CONNACK, SUBSCRIBE→SUBACK(QoS 최대 1 부여) 뒤 **매칭되는 retained 전달**, PUBLISH QoS1 은 **라우팅·retained 저장 뒤에 PUBACK**(b58edac — 발행자가 ack 를 받은 시점에 브로커 상태가 이미 반영됨), 빈 payload retained 는 삭제, UNSUBSCRIBE→UNSUBACK, PINGREQ→PINGRESP, `+`/`#` 와일드카드와 `$` 토픽 예외(`topic_matches`). 미지원(QoS2·will·지속 세션)은 이 저장소가 쓰지 않는 기능이다. `127.0.0.1:0` 에 바인딩해 **포트 충돌이 없고**, 스레드는 daemon 이며 `stop()` 이 리스너·연결을 닫는다.
|
||||
|
||||
이전된 시험의 단언은 **약해지지 않았고 강해졌다**:
|
||||
- `test_acceptance5`: 공개 브로커용 `_skip_if_broker_did_not_deliver`·`no CONNACK` skip 이 삭제되고, 대신 러너 로그의 `DISPATCH`·`published started/completed`·`DONE rc=0` 과 오케스트레이터 감사 기록의 `dispatch_verified`·`dispatch_fingerprint == sha256(token)` 을 양쪽에서 단언한다.
|
||||
- `test_issue19`: "브로커 도달 불가면 skip" 이 `pytest.fail` 로 바뀌었다(로컬 브로커는 항상 떠 있으므로).
|
||||
- S9/S10 의 `_live_broker()`(2·3회차 리뷰에서 내가 P2 로 지적한 공개 브로커 의존)가 `local_broker_env()` 로 대체됐다.
|
||||
- `test_b19` 는 `send_keys_safe` 본문을 앞 2500자가 아니라 **함수 끝까지** 검사하게 고쳐졌다(정확성 개선).
|
||||
- 정적 검사(`test_local_mqtt.py:215`)가 공개 브로커 주소의 재유입을 막는다. 시험 로그에 `hivemq`·실제 브로커 IP 흔적 0건.
|
||||
|
||||
## 6. 테스트 실행 (검증 방법·검토 항목 6)
|
||||
|
||||
```
|
||||
cd <검증 경로> && AGENT_PYTHON_BIN=<pr-43 .venv> PYTHONDONTWRITEBYTECODE=1 <pr-43 .venv> -m pytest -p no:cacheprovider -q -rfEs \
|
||||
--basetemp=<밖> tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py \
|
||||
tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py tests/test_issue30_stall_watch.py tests/test_tier1_unit.py
|
||||
(.mam.env 미export, MQTT_* 미설정)
|
||||
→ 228 passed, 1 skipped in 525.44s (0:08:45) failed 0 (skip = S6, /proc)
|
||||
```
|
||||
|
||||
- PR #41 리뷰 내내 공개 브로커 때문에 흔들리던 S9·S10·R2·B3·acceptance 계열이 **외부 브로커 없이 전부 통과**했다 — PR 의 주장 확인.
|
||||
- **기준 실패 주장(검토 항목 6)**: `tests/test_issue10_13_purge.py` 를 이 PR 트리와 base(`e1eb22b` 를 `git archive` 로 풀어 놓은 사본)에서 각각 단독 실행한 결과 **둘 다 23 passed**, 실패 0. 즉 이 호스트에서 이 리뷰어의 조건으로는 "12건 기준 실패" 가 **재현되지 않는다**. 양쪽 결과가 같으므로 어느 경우든 PR 회귀는 아니다. 오케스트레이터의 전체 스위트에서만 나타난다면 시험 간 순서·상태(예: 실제 홈의 대화 파일, 이전 시험이 남긴 환경) 의존을 의심할 만하다(R4).
|
||||
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `b58edac`), 본 체크아웃 `main e1eb22b`. 실행 뒤 호스트에 남은 `stall_watch`(이 잡을 감시하는 오케스트레이터의 것)·`sleep 300`(다른 세션의 프로세스 그룹) 은 내 실행과 무관함을 부모/프로세스 그룹으로 확인했다.
|
||||
|
||||
## 7. 설계 경계 (검토 항목 7)
|
||||
|
||||
- **판정이 Python 으로 이동**: `.mam.env` 해석(키 검증·예약 변수 거부·우선순위)이 bash `source` 에서 `envfile.py` 로 옮겨졌다 — #17 항목 4 의 "`.mam.env` 파싱(#26) — bash `source` 제거" 그대로다. 래퍼에는 실행과 `eval` 만 남는다.
|
||||
- **bash 에 새로 생긴 로직**: `mam_int`/`mam_int_knob` 과 `parse_opts` 의 플래그 검증. 이는 bash 산술로 들어가는 값을 그 자리에서 정규화하는 **입력 정제**이고, Python 쌍둥이(`_positive_int`)와 한 규칙으로 고정됐다(B1 시험). 판정(무엇을 할지)이 아니라 형식 변환이라 경계 위반으로 보지 않지만, 1회차 PR #41 리뷰의 M4(`MAM_JOB_TIMEOUT_S` 검증이 bash 에 있음)와 같은 부류이므로 항목 4 에서 래퍼 인자 처리가 Python 으로 갈 때 함께 옮길 목록에 둘 것을 권한다(R5).
|
||||
|
||||
## 8. 판정
|
||||
|
||||
버전 락스텝 bump 부재는 판정에서 제외한다. 병합을 막을 결함은 없다.
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| R1 | `.mam.env.example` 머리말에 새 파싱 규칙과 **env-wins** 가 적혀 있지 않다 — 래퍼 사용자에게 `export` 접두어·`$` 확장·인라인 주석·우선순위 4가지가 동작 변경 | `.mam.env.example:1-20`; §1·§3 |
|
||||
| R2 | 래퍼의 `ERROR: cannot read $TARGET_ENV` 는 Python 실행 실패에만 걸리고, 실제 읽기 실패는 경고 후 설정 없이 진행 — 메시지가 원인과 어긋난다 | `multi-agent-mux-delegate-job:44`, `envfile.py:52-53` |
|
||||
| R3 | `[ -ge ]`/`sleep` 전용 정수 노브에 숫자가 아닌 값이 오면 `[` 오류(rc 2)를 내는 기존 동작은 남는다 | PR 이 분류표로 의도적으로 범위 밖 처리 |
|
||||
| R4 | "test_issue10_13_purge.py 12건 기준 실패" 가 이 리뷰어 조건(단독, PR·base 양쪽)에서는 재현되지 않음 — 전체 스위트 순서 의존 가능성 | §6 |
|
||||
| R5 | `mam_int`/플래그 검증은 bash 의 입력 정제 — 항목 4 이전 목록에 추가 권고 | `lib.sh:2345-2364`, 래퍼 `:196-200` |
|
||||
|
||||
## 9. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # b58edac… / 14b670f…
|
||||
# 합성 .env 15줄을 envfile.apply 와 `-I envfile.py --shell` + eval 양쪽에 → 결과 동일, $(…)/`…` 미실행 (§1 표)
|
||||
# mam_int vs _positive_int 15개 입력 → 전부 동일 (§4)
|
||||
# base: git -C "$RP" archive e1eb22b | tar -x -C <tmp> → tests/test_issue10_13_purge.py → 23 passed (PR 도 23 passed)
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,112 @@
|
||||
# 🔍 Review Report — PR #41·#43 후속 `fix/followups-41-43` (job `76ca3b1b`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-02
|
||||
- **대상**: `0051fd6..95eabc6` (8커밋, 27파일 `+397/−88`, `git diff --check` 클린), base `main 0051fd6`.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T230324Z-b511bf/1`. **identity 확인**: `HEAD = 95eabc69…d5f4f`, `HEAD^{tree} = 46399844…7998` — 브리프와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/followups`)·본 체크아웃 모두 수정·커밋·checkout 없음. 되돌린 사본·합성 파일·`ps` 대역은 scratchpad 에서만. 실제 `.mam.env` 를 읽거나 출력하지 않았고 export 하지 않았다. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**F1–F6 모두 완료 조건을 만족한다. 범위 밖 동작 변경은 없다.** 제품 코드를 바꾸는 항목(F2·F3·F4·F5)의 회귀는 제품 파일만 base 로 되돌린 사본에서 **47건 실패**한다(아래 §7). 테스트만 고치는 항목(F1·F6)은 base 제품 코드에서도 통과하는 것이 정상이며, F1 은 옛 테스트가 날짜에 따라 실패하던 원인을 `ps` 대역으로 직접 재현했다. 비차단 관찰 3건(T1–T3).
|
||||
|
||||
## F1 — A8 의 날짜 의존 (`tests/test_issue21_acceptance.py:258-300`)
|
||||
|
||||
- 제품 코드(`loop_lock.sh` 의 `mam_lstart`)는 **그대로**다. `loop_lock.sh` 의 diff 는 F4b 의 `MAM_LOCK_STEAL_RETRIES` 한 줄뿐이다.
|
||||
- 테스트는 마커의 lstart 를 `" ".join(out.split())` 로 써서 제품의 `tr -s ' '` + 앞뒤 공백 제거와 같게 맞췄다.
|
||||
- 날짜와 무관하게 고정하는 장치: `padded` 파라미터가 PATH 앞에 `ps` 대역을 둔다. 이 대역은 `lstart=` 질의에만 `Thu Oct 1 08:02:03 2026`(두 칸)을 돌려주고, 대역이 실제로 쓰였는지도 단언한다.
|
||||
- **옛 버그 재현(직접)**: 같은 대역 아래에서 제품 `mam_lstart` 는 `Thu Oct 1 …`(한 칸), 옛 테스트 방식(`.strip()`)은 `Thu Oct 1 …`(두 칸)을 낸다 → 불일치 → 잠금이 "stale" 로 보여 두 번째 루프가 거부되지 않는다. 즉 옛 A8 이 1~9일에 실패한 원인이 정확히 이것이고, 새 `padded` 케이스는 매일 그 조건을 돈다.
|
||||
- 같은 패턴의 다른 테스트: `test_o3_scoped_guard.py:_get_lstart` 는 이미 `" ".join(...split())` 로 정규화한다. `test_o2_race_free_lock.py:get_lstart` 는 정규화하지 않지만 **어디서도 호출되지 않는 죽은 헬퍼**다(T1, 지우거나 정규화 권고).
|
||||
|
||||
## F2 — 진입점의 한 줄 오류 (`mqtt_common.py:129-140` 외 7곳)
|
||||
|
||||
- 공통 헬퍼 `load_env_or_report()` 가 `EnvFileError` 를 잡아 `ERROR: <path>: cannot read (<type>)` 한 줄을 출력하고 False 를 돌려준다. 라이브러리 함수는 `sys.exit` 하지 않는다 — 진입점(`publish_event`·`registry`·`job_subscriber`·`cli`·`bench`·`heartbeat`·`remote` 의 `main`)이 `return 1` 한다. `job_subscriber` 는 `RC_ERROR`(=1)로 이름을 썼다.
|
||||
- 값·파일 내용은 출력되지 않는다(예외 메시지가 경로와 예외 종류뿐).
|
||||
- 회귀 `test_a1_…[7 진입점 × permission/utf8]` 14건이 base 에서 모두 실패한다.
|
||||
- reconcile.sh 의 내장 구독자는 여전히 `ensure_env_loaded()` 를 직접 부른다(브리프 목록 밖, 문서화된 그대로).
|
||||
|
||||
## F3 — `.mam.env` 세부
|
||||
|
||||
- (a) BOM: `envfile.py:60-61` 이 `utf-8-sig` 로 연다. 공통 파서라 **두 로더 모두** 적용된다. 파일 맨 앞의 BOM 만 벗기므로 중간의 U+FEFF 는 그대로다.
|
||||
- (b) 중복 키는 첫 값 우선: 동작은 그대로(`apply` 가 `if key not in environ`)이고 `.mam.env.example` FORMAT 절에 한 줄 추가했다.
|
||||
- (c) `MESSAGING.md:369`: "명시한 파일이 없으면 오류" 를 "조용히 건너뜀(주변 탐색은 안 함), 있는데 못 읽으면 EnvFileError" 로 실제 동작에 맞췄다.
|
||||
- 회귀 `test_a7_*` 3건이 base 에서 실패한다.
|
||||
|
||||
## F4 — 정수 노브
|
||||
|
||||
- (a) 러너(`runner.py:627`, `:630-643`, `:762-768`): `_int_knob` 이 `_positive_int` 규칙으로 읽고, 잘못된 값이면 노브당 한 번 경고하고 기본값을 쓴다. `MAM_CHILD_WAIT_S=abc` 의 `ValueError` 가 사라졌다.
|
||||
- 최솟값의 의미도 맞다.
|
||||
|
||||
| 노브 | 최솟값 | 의미 |
|
||||
|---|---|---|
|
||||
| `MAM_WAIT_GRACE_S` | 0 | 예산만큼 정확히 대기 |
|
||||
| `MAM_CHILD_WAIT_S` | 0 | 하위 잡을 한 번만 확인 |
|
||||
| `MAM_JOB_TREE_MAX` | 1 | 0 이면 모든 트리가 "초과"; 대기하지 않으려면 `MAM_CHILD_WAIT_S=0` |
|
||||
|
||||
세 값 모두 표 `RUNNER_INT_KNOBS` 에 들어갔다.
|
||||
- (b) bash 노브: `MAM_READY_EMPTY_GIVEUP`·`SKS_EMPTY_GIVEUP`(min 1), `SKS_IDLE_RETRIES`·`SKS_IDLE_RETRY_BACKOFF_S`(min 0), `MAM_LOCK_STEAL_RETRIES`(min 0), `MAM_DIFF_MAX_BYTES/LINES`(min 1), `RECONCILE_POLL_INTERVAL`(min 1 — 0 이면 바쁜 루프)가 `mam_int_knob` 을 거친다.
|
||||
- `SKS_QUIESCENT_INTERVAL` 은 기본값이 소수(0.5)라 **그대로 두고 표에 사유**를 남겼다 — 브리프 지시대로다.
|
||||
- `RECONCILE_POLL_INTERVAL` 은 저장소 안의 모든 사용처(테스트 `60`, 문서 15 s)가 정수라 의미가 깨지지 않는다.
|
||||
- `diff_collect.sh`·`loop_lock.sh` 는 lib.sh 없이 source 되므로 규칙을 `lib_int.sh` 로 분리했다. lib.sh 도 같은 파일을 source 한다(`declare -F` 가드로 이중 정의를 막음). `deploy/remove.sh` 의 폴백 자산 목록에 `lib_int.sh` 를 추가한 것은 새 파일을 배포 제거 대상에 넣는 **필수 동반 변경**이다(범위 안). `test_issue21_loop_b.py` 의 한 줄도 L15 가 `diff_collect.sh` 를 복사해 쓰므로 필요한 동반 수정이다.
|
||||
- (c) loop SKILL.md 의 Job budget 행에 `MAM_WAIT_GRACE_S`(기본 300, 0 이상, 잘못된 값은 경고 후 300)를 문서화했다.
|
||||
- 회귀 `test_b1_runner_*`·`test_b5_*` 가 base 에서 실패한다.
|
||||
|
||||
## F5 — 테스트 스위치 제거 (`stall_watch.py`, `test_issue30_stall_watch.py`)
|
||||
|
||||
- `MAM_TEST_UNRELIABLE_AGENT` 두 줄을 삭제했다.
|
||||
- S13 은 `-c` 진입점에서 어댑터 클래스의 `agent_status_reliable` 을 `False` 로 바꾼 뒤 **실제** `stall_watch.main` 을 돈다. 제품에는 분기가 없고, 검증하는 내용(idle 판정 끔, blocked 알림 유지)도 같다.
|
||||
- 새 `test_s13_the_unreliable_case_needs_no_switch_in_the_product`(`MAM_TEST_` 문자열 부재)가 base 에서 실패한다.
|
||||
|
||||
## F6 — 문서·테스트 정밀도
|
||||
|
||||
- delegate-job SKILL.md:53 은 이제 "≤ 60 s 는 herdr wait 한정, 알림 **발행 중**에 래퍼가 사라지면 그 발행이 끝날 때까지" 라고 적는다 — 코드(`_wrapper_gone` 이 wait 직후와 `_may_prompt` 에만 있음)와 일치한다.
|
||||
- "대기 중 래퍼 소멸" 테스트는 이제 **두 번째 wait 대역 안에서** `gone` 을 세우고, `ticks == [1, 2]` 와 마지막 alive 관측이 `(2, False)` 임을 단언한다. 이름 그대로를 강제한다.
|
||||
- 테스트 정밀화라 base 제품 코드에서도 통과하는 것이 맞다(제품 동작은 PR #41 에서 이미 옳았다).
|
||||
|
||||
## 7. 테스트
|
||||
|
||||
### 7-1. 검증 경로에서의 실행
|
||||
|
||||
```
|
||||
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_issue21_acceptance.py tests/test_issue30_stall_watch.py
|
||||
tests/test_issue30_stall_watch_stop.py tests/test_tier1_unit.py tests/test_version_consistency.py tests/test_issue5_phase1_delegation.py
|
||||
(.mam.env 미export, MQTT_* 미설정, followups .venv)
|
||||
→ 335 passed, 1 skipped in 451.38s failed 0 (skip = S6, /proc)
|
||||
```
|
||||
|
||||
브리프 목록 7파일에 `test_issue5_phase1_delegation.py`(registry CLI 스냅샷·help 캡처)를 더해, 진입점 변경이 CLI 계약을 깨지 않음을 확인했다. 로그에 외부 브로커 흔적 0건.
|
||||
|
||||
### 7-2. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 `tests/` 밖의 바뀐 파일 전부를 `0051fd6` 판으로 되돌리고(새 `lib_int.sh` 는 삭제) 새·변경 테스트를 돌렸다:
|
||||
|
||||
```
|
||||
47 failed, 88 passed in 110.20s
|
||||
```
|
||||
|
||||
| 항목 | base 에서 실패한 회귀 |
|
||||
|---|---|
|
||||
| F2 | `test_a1_python_entry_points_report_an_unreadable_file_in_one_line` 14건(7 진입점 × 권한/UTF-8) |
|
||||
| F3 | `test_a7_a_leading_bom_…`, `…_repeated_key_keeps_its_first_value…`, `…_missing_explicit_file_…` |
|
||||
| F4 | `test_b1_runner_wait_grace/child_wait/job_tree_max_*`, `test_b5_*`(표·최솟값·lib.sh 없이 source 되는 두 스크립트·`_pane_quiescent`) |
|
||||
| F5 | `test_s13_the_unreliable_case_needs_no_switch_in_the_product` |
|
||||
| F1·F6 | 테스트만 고친 항목이라 base 제품 코드에서 통과하는 것이 정상. F1 은 §F1 의 `ps` 대역 실험으로 옛 테스트의 실패 원인을 직접 재현 |
|
||||
|
||||
### 7-3. 격리
|
||||
|
||||
실제 `.mam/jobs` 는 이 잡 자신의 파일 외 **추가 0**. 검증 경로 tracked 파일 변화 없음(HEAD `95eabc6`), 본 체크아웃 `main 0051fd6`.
|
||||
|
||||
## 8. 범위
|
||||
|
||||
바뀐 파일 27개를 모두 F1–F6 또는 그 필수 동반 변경으로 귀속할 수 있다.
|
||||
- `lib_int.sh` 분리와 그에 따른 `deploy/remove.sh`·`test_issue21_loop_b.py` 의 한 줄은 F4b 의 동반 변경이다.
|
||||
- #17 항목 4·#39·#40·#42 범위의 변경과 버전 bump 는 없다.
|
||||
|
||||
## 9. 판정
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| T1 | `test_o2_race_free_lock.py:get_lstart` 는 정규화하지 않는 **호출되지 않는** 헬퍼 — 지우거나 같은 정규화로 맞추면 재발 여지가 없다 | `tests/test_o2_race_free_lock.py:40-43` |
|
||||
| T2 | reconcile.sh 의 내장 구독자는 `ensure_env_loaded()` 를 직접 불러 읽기 실패 시 여전히 트레이스백 | 브리프 진입점 목록 밖; 다음 정리 때 `load_env_or_report` 로 |
|
||||
| T3 | (확인 완료, 조치 불필요) `deploy/install.sh` 는 트리 전체를 복사하므로(`cp -R "$REPO_URL/."`, :128) `lib_int.sh` 도 함께 배포된다. `check_assets_present`(:98-108)는 존재 확인용 목록이라 `lib.sh` 만 있어도 되지만, lib.sh 가 lib_int.sh 를 source 하므로 목록에 추가해 두면 부분 설치를 더 일찍 잡는다 | `deploy/install.sh:98-108` |
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,95 @@
|
||||
# 리뷰 리포트 — PR #59 2차 (Issue #31) / job 778fc866
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Opus 5.5)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261017T135731Z-e93f0a/1`
|
||||
- identity: HEAD `2a8d73825b3832376aeaf96b09548359dc3cb887`, tree `d146f1ac68bcc3485d24fb92edb87264e4d1e8cb` 일치. 트리는 clean이고 `.mam` 없음(테스트 후에도 동일).
|
||||
- 범위: 26e2c43..2a8d738 누적, 중점은 1차 이후 커밋 11개(26fb1c4..2a8d738)
|
||||
|
||||
## 요약
|
||||
|
||||
1차 수정 요청 3건(차단 F1, P1, P2)은 **제품 코드에서 모두 해결됐다**. 비차단 메모(서머타임/UTC, exit 4 안내, SKILL.md 문구, 테스트의 실제 `/tmp` 나열)도 해결됐다. 샌드박스 재현과 변형(mutation) 대조로 각 수정과 회귀 테스트가 실제로 맞물리는 것도 확인했다.
|
||||
|
||||
그러나 **새로 추가된 테스트 `test_any_spelling_of_the_uuid_is_the_lowercase_session[upper|mixed]` 는 이 Mac 에서 항상 실패한다**(T1). 원인은 제품 코드가 아니라 테스트의 macOS 이식성 버그다. 이대로 병합하면 이 Mac 의 스위트가 빨간불이 되므로 NOT PASS 로 판정한다. 수정은 테스트 한 줄이다.
|
||||
|
||||
## 차단 결함
|
||||
|
||||
### T1. `sh -c "sleep 120" <uuid>` 보유자 프로세스가 macOS 에서 uuid 를 argv 에서 잃는다
|
||||
|
||||
- 위치: `tests/test_issue31_purge_residual.py:315-318`
|
||||
```python
|
||||
named = subprocess.Popen(["sh", "-c", "sleep 120", conv])
|
||||
time.sleep(0.05)
|
||||
assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id"
|
||||
```
|
||||
- 원인: 이 Mac 의 `/bin/sh` 는 GNU bash 3.2.57 이다. bash 는 `-c` 의 단일 단순 명령을 exec 로 바꿔 실행하므로, 실행된 프로세스의 argv 는 `sleep 120` 만 남고 `$0` 인 uuid 는 사라진다.
|
||||
- 직접 확인: `/bin/sh -c 'sleep 30' MARKER-abc &` → `ps` 에 `sleep 30` 만 보인다.
|
||||
- `/bin/sh -c 'sleep 30; :' MARKER-abc` → `/bin/sh -c sleep 30; : MARKER-abc` 로 uuid 가 남는다.
|
||||
- 이 저장소도 같은 함정을 이미 기록해 두었다: `tests/test_issue10_13_purge.py:114` ("a shell execs its last simple command and the uuid …").
|
||||
- 재현: 검증 경로의 관련 모듈 실행(아래)에서 **2 failed**. 수정하지 않은 `git clone` 사본에서 `-k any_spelling` 만 돌려도 2 failed:
|
||||
`AssertionError: a command line naming the lowercase id … conversation_in_use('91151A0D-0114-4C3C-a120-aec043818d1f', …) → False`
|
||||
아래 변형 대조 4종(F1/P1/P2/UTC)에서도 변형과 무관하게 같은 2개가 실패했다.
|
||||
- 제품 로직은 정상이다. argv 를 유지하는 보유자(`["sh","-c","sleep 30; :", <lowercase uuid>]`)로 샌드박스에서 확인했다:
|
||||
- `conversation_in_use(UPPER)` = True, `conversation_in_use(lower)` = True.
|
||||
- 단일 명령 보유자로는 두 철자 모두 False.
|
||||
- 제안: 보유자를 `["sh", "-c", "sleep 120; :", conv]` 나 `[sys.executable, "-c", "import time; time.sleep(120)", conv]` 처럼 uuid 가 argv 에 남는 형태로 바꾼다. 고정 `time.sleep(0.05)` 대신 짧은 폴링(예: 최대 2 s 동안 `ps` 에 보일 때까지)으로 기다리면 느린 호스트의 경합도 피할 수 있다.
|
||||
|
||||
## 1차 지적사항 확인
|
||||
|
||||
샌드박스 조건: HOME·CLAUDE_PROJECT_DIR·CLAUDE_CODE_TMPDIR·TMPDIR 를 모두 스크래치패드 아래로 두고, CLAUDE_CONFIG_DIR 은 비웠다. 인터프리터는 `/usr/bin/python3` 3.9.6. 실제 경로는 읽기만 했다.
|
||||
|
||||
1. **[차단] 비-UUID id — 해결.**
|
||||
- 방식: `_canonical()`(`claude.py:8-19`)이 정규 UUID 만 받아 소문자로 바꾼다. `_candidates`·`purge_artifacts`·`residual_artifacts`·`conversation_in_use` 의 모든 입구가 이 함수를 거친다.
|
||||
- 재현: 아래 14개 id 는 purge 결과가 모두 rc 5 이고, 아무것도 지우지 않았다. `projects/<key>/memory/MEMORY.md` 와 다른 세션 파일은 전부 남았다.
|
||||
- `""`, `.`, `..`, `a/b`, `*`, `../x`
|
||||
- `memory`, `MEMORY`, `subagents`, `<key>`
|
||||
- uuid 앞 8자리, `<U>x`, `" <U>"`, `"<U>\n"`
|
||||
- 대문자 uuid 로 purge 하면 소문자 경로 9개가 정확히 지워진다(`lower jsonl gone True`). 다른 세션·공유 파일은 모두 보존됐다.
|
||||
- 변형 대조(`_canonical` 을 옛 `_plausible_id` 규칙으로 되돌림): `rejects_malformed_uuid[memory, ' ', notes, …g, '{conv} ', 'x{conv}']`, `a_non_uuid_id_reaches_nothing[uuid-c, memory, notes]`, `any_spelling` 이 실패해 **11 failed**.
|
||||
2. **[P1] 링크 경계 — 해결.**
|
||||
- 방식: `_inside()`(`claude.py:109-123`)는 루트와 대상 사이의 디렉터리 구성요소를 `lstat` 으로 하나씩 확인하고, 부모의 realpath 가 루트의 realpath 아래에 있는지 본다.
|
||||
- 링크로 연결된 tmp `<key>`, `file-history/`, `projects/<key>` 뒤의 내용은 모두 보존되고 `residual` 로 보고된다.
|
||||
- 말단 링크(`session-env/<U> → 외부`)는 링크만 지워지고 대상 파일은 남는다.
|
||||
- 루트 자체가 링크인 경우(`~/.claude → realhome`, `CLAUDE_CODE_TMPDIR → realtmp`, 즉 `/tmp → /private/tmp` 같은 형태)는 정상적으로 purge 된다.
|
||||
- Python 3.9 macOS 의 `shutil._use_fd_functions` 는 True 로, rmtree 가 내부 링크를 따라가지 않는다.
|
||||
- 변형 대조(`_inside` → 항상 True): `never_follows_a_linked_dir_below_a_root`, `reports_a_linked_orphan_it_will_not_remove` 실패(T1 의 2개 포함 **4 failed**).
|
||||
3. **[P2] 유한하지 않은 startedAt — 해결.**
|
||||
- 방식: `claude.py:84-96`.
|
||||
- 살아 있는 `sleep` 보유자에 대해 startedAt 을 아래 9가지 값으로 바꿔 봤다. 모두 in-use rc 4, purge rc 4 였고 jsonl 은 보존됐다.
|
||||
- `NaN`, `Infinity`, `-Infinity`, `"NaN"`, `true`, `1e400`, 400자리 정수, `null`, `"1760000000000"`
|
||||
- 정상 ms 값이면 live(rc 4), 1시간 전 값(pid 재사용)이면 free 다.
|
||||
- 변형 대조(`isfinite` 제거): `unusable_started_at_means_live[nan]`, `live_holder…[NaN]` 실패. inf 계열은 다른 분기에서도 live 로 판정되므로 이 변형으로는 실패하지 않는 것이 맞다.
|
||||
|
||||
## 비차단 메모 확인
|
||||
|
||||
- **UTC (af8ed39)**: 실제 살아 있는 claude 4개를 읽기 전용으로 비교했다(`TZ=UTC0 LC_ALL=C ps -o lstart=` + `calendar.timegm`).
|
||||
- lstart 가 세션 파일의 `procStart`(UTC)와 초 단위까지 일치했다. 예: `Sat Oct 17 13:55:10 2026`.
|
||||
- startedAt − lstart 는 1.34 / 1.49 / 1.99 / 2.36 s 로, 모두 live 판정이다.
|
||||
- 변형 대조(mktime·로컬 TZ 로 되돌림): `liveness_reads_lstart_as_utc` 실패.
|
||||
- **exit 4 안내 (e156179)**: in-use stderr 가 두 가지를 출력한다.
|
||||
- "a claude process whose session file under <cfg>/sessions/ names it" (CLAUDE_CONFIG_DIR 을 반영)
|
||||
- "ps -axww | grep <uuid>"
|
||||
- `stop_session.sh:292` 의 `h="$(… 2>&1 >/dev/null)" || rc=$?` 는 `/bin/bash` 3.2 + `set -euo pipefail` 에서 rc 4 와 안내 문구를 올바르게 잡고, 안내가 비었을 때도 스크립트가 계속 진행된다(직접 확인). `bash -n` 통과.
|
||||
- **SKILL.md exit 4 문구**: "Records preserved … the session itself may already be down" 으로 정확해졌다.
|
||||
- **테스트의 실제 /tmp (26fb1c4)**: autouse 픽스처가 `_SYSTEM_TMP` 를 테스트 디렉터리로 패치하고, `test_tests_never_reach_the_real_tmp_root` 가 하위 프로세스까지 확인한다.
|
||||
- **agy 게이트**: 동작 변화는 그대로다(fail-closed). 실제 agy 로는 확인하지 않았다(브리프상 금지).
|
||||
|
||||
## 회귀 관점 (dd292c1 등)
|
||||
|
||||
- `has_artifacts` 는 링크 뒤의 후보도 포함하므로 `purge auto` 가 claude 를 고른다. 이때 purge 는 아무것도 지우지 않고 residual 만 보고한다(위 links 재현: rc 0, `purged` 에는 말단 링크 1개만 있고 `residual` 4개). 의도대로다.
|
||||
- 동작이 바뀌는 지점이 하나 있다(비차단): `projects/<key>` 자체가 심볼릭 링크인 사용자는 이제 transcript jsonl 이 purge 되지 않고 residual 로만 보고된다. 반면 `discover`/`verify_artifact` 는 링크를 따라가 그 대화를 계속 찾는다. P1 요구("링크를 따라가지 않음")의 결과이고 residual 로 드러나므로 수용 가능하지만, SKILL.md 에 한 줄 적어 두면 좋다.
|
||||
- 철자 비대칭(비차단): 소문자 id 로 질의하면 대문자 argv 보유자는 보지 못한다(`claude -r <UPPER>` 로 띄운 경우). claude 가 소문자로 기록하는 한 실질 영향은 없다.
|
||||
- `base.in_use_hints` 추가와 `__main__` 의 in-use stderr 출력은 다른 어댑터에 기본 안내만 준다. 회귀 없음.
|
||||
- `test_a4_adapter_contract` 의 claude id 를 실제 uuid 로 바꾼 것은 새 계약(정규 UUID 만 허용)에 따른 정당한 갱신이다.
|
||||
- Python 3.9 파싱(`feature_version=(3,9)`)과 `/usr/bin/python3` 실행(모든 프로브)에서 문제 없었다.
|
||||
|
||||
## 테스트
|
||||
|
||||
- 검증 경로, `env -i`(HERDR_*·WORKSPACE_ROOT·MAM_*·DELEGATE_JOB_LOGS_DIR·CLAUDE_CONFIG_DIR 미설정), 스크래치패드 basetemp, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider`.
|
||||
- 실행 모듈: `test_issue31_purge_residual, test_issue10_13_purge, test_a4_adapter_contract, test_issue42_skill_blocks, test_issue55_agent_homes, test_orc_onboard, test_uuid_target, test_tier1_unit, test_tier2_component`
|
||||
- 결과: **2 failed (T1), 330 passed, 1 skipped** (630 s).
|
||||
- 사본에서 `test_issue31_purge_residual.py` 를 반복 실행한 결과:
|
||||
- 비변형 사본: 2 failed (T1) / 65 passed.
|
||||
- P2 변형 사본: 첫 실행에서 `test_grok_residual_reports_memory_observations` 가 한 번 ERROR 였다. 이후 재실행 4회에서는 재현되지 않아 일회성으로 보고만 한다.
|
||||
- 6f4f40a 의 제품 파일로 통째로 되돌린 대조는 쓸 수 없었다. autouse 픽스처가 26fb1c4 에서 추가된 `claude._SYSTEM_TMP` 를 패치하므로 67개 모두 setup ERROR 가 난다. 그래서 위의 수정별 변형 대조로 대신했다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261013T010208Z-f90dd4",
|
||||
"base_sha": "325b056057fcdd717d2c81017fc5ef57183cb4d0",
|
||||
"head_sha": "db7f4cab9c60ff5db009cf3fc625e9a86a465ef6",
|
||||
"tree_sha": "f32cabd3a6c8d775c9223509967f1a810f0cf827",
|
||||
"review_commit": "db7f4cab9c60ff5db009cf3fc625e9a86a465ef6",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "834fa032",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-13T01:21:50Z"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# 테스트 실제 herdr 서버 누수 수정 2차 리뷰 — 커밋 db7f4ca (job 834fa032)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261013T010208Z-f90dd4 / base 325b0560 .. head db7f4cab (커밋 1개, 2 파일) / tree f32cabd3
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T010208Z-f90dd4/1` — HEAD·tree 일치, `git status --porcelain` 0 줄(시작·종료), `.mam` 생성 없음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
|
||||
- 실행 안전: 1차와 같이 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 변수는 명시 추가)로 돌려 브리프의 `-u` 목록 전부가 비워진다. basetemp 는 scratchpad 아래(이름에 "shim" 없음). 실제 바이너리는 일회용 HOME 에서 `status server` 읽기만(서버 미기동). Python 은 본 체크아웃 `.venv`(3.9.6). 전체 스위트는 돌리지 않았다. 범위는 이 커밋만.
|
||||
|
||||
## 1. 변경
|
||||
- `tests/conftest.py:299`: `mam_sandbox` 가 `HERDR_SESSION` 도 지운다(주석 `:296-297`). 현재 제거 목록: `HERDR_CONFIG_PATH`, `HERDR_PANE_ID`, `HERDR_SERVER_NAME`, `HERDR_SESSION_NAME`, `HERDR_SESSION`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID`, `XDG_CONFIG_HOME`.
|
||||
- `tests/test_herdr_real_server_leak.py:76`: L2 의 `PANE_VARS` 에 `HERDR_SESSION` 추가. 수정 전 conftest(325b056)에 새 테스트를 얹은 scratch 복사본에서 L2 는 `AssertionError: ['HERDR_SESSION']` 로 실패 → 테스트가 이 변경을 정확히 겨눈다.
|
||||
- Python 3.9 컴파일 통과. 제품 코드 변경 없음.
|
||||
|
||||
## 2. HERDR_SESSION 을 지워서 깨지는 테스트가 있는가 (검토 관점 1)
|
||||
- 소비자: `lib.sh:1425 _herdr_caller_server`, `lib_py/pane_lock.py:49 caller_server`, `lib_py/loop/watcher.py:64` — 셋 다 "있으면 호출자 서버, 없으면 `HERDR_SOCKET_PATH` 모양" 이고 `HERDR_SOCKET_PATH` 는 이미 지워지므로 샌드박스에서는 일관되게 "pane 밖" 으로 판정된다.
|
||||
- 테스트 쪽 bare `HERDR_SESSION` 참조는 전부 테스트가 **스스로** 넣는 값이다(픽스처 뒤라 영향 없음): `test_issue23_24_startup_path.py:264,369,395`(S1g·watcher 케이스는 `env={...}` 로 명시), `test_issue34_job_tree.py:580`, `test_issue30_stall_watch.py:486`, `test_issue45_restore.py:42`(`SCOPE_ENV` 로 이미 `HERDR_SESSION`·`HERDR_ENV` 까지 자체 제거). 반대로 "`HERDR_SESSION` 이 없어야" 하는 테스트(`test_s2b…:188`, S2 계열 "pane 밖 호출자") 는 이제 pane 안에서 돌려도 전제가 보장된다 — 이것이 이 변경의 목적이다.
|
||||
- 실측: 같은 6 파일(`test_herdr_real_server_leak`, `test_issue50_pane_lock`, `test_issue23_24_startup_path`, `test_herdr_shim_contract`, `test_issue45_herdr_config`, `test_issue9_server_env`)을 (a) 깨끗한 환경, (b) 바깥 환경에 `HERDR_SESSION=pane-real-srv` 를 넣은 환경에서 각각 돌렸다: **둘 다 139 passed, 34 skipped** (skip 은 모두 Python 3.9 의 tomllib skip). pane 안/밖 결과가 같다.
|
||||
|
||||
## 3. 다른 라우팅 변수가 남았는가 (검토 관점 2)
|
||||
- 내 pane 이 내보내는 `HERDR_*` 는 `HERDR_BIN_PATH`, `HERDR_CONFIG_PATH`, `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_SESSION`, `HERDR_SESSION_NAME`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, `HERDR_WORKSPACE_ID` 아홉 개. 샌드박스가 지우지 않는 것은 `HERDR_BIN_PATH`·`HERDR_ENV` 둘뿐이다.
|
||||
- 실제 바이너리(0.9.3) 에 각각을 단독으로 주고 `status server` 를 읽었다: `HERDR_ENV`, `HERDR_BIN_PATH`, `HERDR_CONFIG_PATH`, `HERDR_WORKSPACE_ID`, `HERDR_PANE_ID` 모두 소켓은 `$HOME/.config/herdr/herdr.sock`(가짜 HOME) — 서버 선택에 영향 없음. 1차에서 확인한 대로 소켓을 바꾸는 것은 `HERDR_SOCKET_PATH`(최우선)와 `HERDR_SESSION`(HOME 상대) 둘뿐이며 이제 둘 다 제거된다.
|
||||
- `.agents`·`tests` 에서 `HERDR_BIN_PATH`·`HERDR_ENV` 를 읽는 코드는 없다(`test_issue45_restore.py:42` 가 자체 정리 목록에 넣어 둔 것뿐). 남은 라우팅 변수 없음.
|
||||
|
||||
## 4. 잔류물
|
||||
- scratch 복사본·basetemp·로그는 scratchpad 아래. 일회용 HOME `/tmp/j834-h` 제거(서버 미기동). 실제 `.mam`·`.mam.env`·모니터·실제 서버·`~/.config/herdr`·에이전트 홈은 건드리지 않았다.
|
||||
|
||||
## 결론
|
||||
`HERDR_SESSION` 제거는 호출자 서버 판정을 pane 안/밖에서 동일하게 만들고, 그 값을 쓰는 테스트는 모두 자체 설정이라 깨지지 않는다(두 환경에서 동일하게 139 passed). 실제 바이너리의 서버 선택에 관여하는 변수는 이제 모두 샌드박스에서 제거된다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260925T072328Z-2f5853",
|
||||
"base_sha": "ed293dcef4c346e0050a31cb46812bcdcf1d67cf",
|
||||
"head_sha": "e80186f56064eaf93a2f32c48d31d84816894774",
|
||||
"tree_sha": "3501868872d828894f031911b8ca31b01ce77a72",
|
||||
"review_commit": "e80186f56064eaf93a2f32c48d31d84816894774",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "895b0370",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-25T07:43:24Z"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 🔍 Review Report — PR #38 델타 리뷰 2회차 (job `895b0370`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-25
|
||||
- **대상 델타**: `ed293dc..e80186f` (2커밋, 3파일 `+73/−10`, `git diff --check` 클린), base `main 7a4a0c2` (v4.7.3).
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260925T072328Z-2f5853/1`. **identity 확인**: `HEAD = e80186f5…4774`, `HEAD^{tree} = 35018688…7a72` — 브리프와 일치.
|
||||
- **1회차 내 판정**: PASS + K1~K4 (job `107d435d`). 이번에는 N1/R1(cancelled 사유)·R2(원격 마감 문구)·K1(STRAY_RULE)의 정정을 본다.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 7a4a0c2`) 모두 수정·커밋·checkout 없음. 실험은 in-process 러너(`env -u MAM_VERSION`, 임시 등록부·감사 로그)와 되돌린 사본에서만. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**세 정정 모두 지적을 닫고, 1회차 판정을 뒤집을 요소는 없다.** 종결 사유 12가지를 `_subjob_state` 에 넣어 분류가 설계대로임을 확인했고(감사 ndjson 경로·`.events.log` 폴백 양쪽), `get_feedback` 의 기본 동작은 그대로다. 되돌린 사본에서 T13×2·T14·T6 만 실패한다. 비차단 관찰 1건(L1).
|
||||
|
||||
## 1. N1/R1 — `cancelled` 사유 (항목 1)
|
||||
|
||||
- `registry.get_feedback(job_id, registry_dir, events=("completed","error"))` (`registry.py:316-317`): 기본값이 종전과 같아 호출자 — 래퍼의 `get-feedback` CLI 3곳(`:556`,`:565`,`:593`)과 `registry.py get-feedback` 서브커맨드(`:668`) — 는 인자 없이 부르므로 **동작 불변**. 실측: `error "first"` 뒤 `cancelled "session_busy"` 가 온 잡에서 기본 호출은 `'first'`, `events=(…,"cancelled")` 는 `'session_busy'`.
|
||||
- `_subjob_state`(`runner.py:684-702`)만 `cancelled` 를 포함해 읽는다(`:691`).
|
||||
- 두 경로 모두 확인: `.events.log` 에만 `cancelled` 가 있는 잡 → `NOT-DISPATCHED/target_not_found`; **감사 ndjson 에만**(`.events.log` 없음, `source_event: cancelled`) 있는 잡 → `NOT-DISPATCHED/session_busy`.
|
||||
|
||||
## 2. R2 — 첫 단어 판정 (항목 1)
|
||||
|
||||
`closed_kind = reason.split(" ", 1)[0]`(`:696`) 을 `ORCHESTRATOR_CLOSED` 와 비교한다. 원격 러너의 실제 문구는 `remote.py:352` 의 `"deadline_timeout (remote runner)"` 다. 오분류 여부를 사유 12가지로 실측:
|
||||
|
||||
| 상태·사유 | 분류 |
|
||||
|---|---|
|
||||
| `error` `deadline_timeout` (래퍼) | CLOSED-UNCONFIRMED |
|
||||
| `error` `deadline_timeout (remote runner)` | **CLOSED-UNCONFIRMED** (R2 정정) |
|
||||
| `error` `agent_blocked` | CLOSED-UNCONFIRMED |
|
||||
| `error` 에이전트 자신의 사유(`tests failed on 3 cases`) | `error`(실패) — 첫 단어 `tests` 는 튜플 밖 |
|
||||
| `error` 사유 없음 | CLOSED-UNCONFIRMED (기존 규칙) |
|
||||
| `cancelled` `target_not_found` / `session_busy` / `rolled back by create_session` | **NOT-DISPATCHED** (N1 정정; `session_busy` 는 첫 단어 판정에 걸리지 않고 cancelled 분기로 간다) |
|
||||
| `cancelled` 사유 없음 / `operator abort` | `cancelled`(실패 집계) |
|
||||
| `completed` `all done` | completed |
|
||||
|
||||
첫 단어 판정이 `session_busy`·`target_not_found`·`rolled` 등을 미확인 종결로 오분류하지 않는다. 남는 이론적 여지는 에이전트가 **자기 사유를 `deadline_timeout`/`agent_blocked` 로 시작하는 문장으로 쓰는 경우**뿐인데, 이 두 단어는 오케스트레이터 예약어라 실무에서 겹치지 않는다(L1, 정보).
|
||||
|
||||
## 3. K1 — `STRAY_RULE` (항목 1)
|
||||
|
||||
`runner.py:41-42` 의 문장은 "귀속되지 않은 열린 잡은 `--parent-job` 없이 등록된 이 작업의 하위 위임일 수 있다; 그 산출물이 작업 목표에 필요하면 NOT PASS" 이고, `_subjobs_block` 은 stray 가 있을 때만 stray 줄 뒤에 붙인다(`:727-729`). SUBJOB_RULE(하위 잡이 있으면 대체 완료 입증이 없는 한 NOT PASS)과 **모순되지 않는다** — stray 는 무관한 잡일 수 있으므로 "필요하면" 이라는 조건부가 맞고, 둘 다 같은 방향(미완 → NOT PASS)이다. stray 가 없으면 프롬프트는 1회차와 바이트 동일(`if not rows and not stray: return ""` 유지).
|
||||
|
||||
## 4. 테스트 (항목 1·2)
|
||||
|
||||
### 4-1. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 `registry.py`·`runner.py` 만 `ed293dc` 판으로 되돌리고 `-k "t13 or t14 or t6 or t5"` 를 돌렸다:
|
||||
|
||||
```
|
||||
4 failed, 3 passed, 13 deselected in 22.39s
|
||||
FAILED test_t13_…[target_not_found] ← N1
|
||||
FAILED test_t13_…[session_busy] ← N1
|
||||
FAILED test_t14_the_remote_runners_deadline_wording_is_closed_unconfirmed ← R2
|
||||
FAILED test_t6_an_unattributed_open_job_is_shown_and_waited_for_only_on_request ← K1 (새 단언)
|
||||
```
|
||||
|
||||
T5/T5b(`deadline_timeout` 정확 일치·늦은 쓰기 무효화)는 양쪽에서 통과 — 기존 분류가 정정으로 바뀌지 않았다. T13·T14 는 실제 `registry.py close` + `publish_event` 로 종결을 만드는 시험이라(mock 아님) 원격 러너의 실제 문구 그대로를 검증한다.
|
||||
|
||||
### 4-2. 검증 경로에서의 실행
|
||||
|
||||
```
|
||||
tests/test_issue34_job_tree.py tests/test_issue5_phase1_delegation.py tests/test_issue14_delivery_ack.py
|
||||
tests/test_loop_cli.py tests/test_issue25_plan_reuse.py tests/test_issue21_loop_b.py
|
||||
→ 158 passed in 271.04s failed 0 · skipped 0
|
||||
```
|
||||
|
||||
새 파일 20건 전부 통과, `registry.py` CLI 스냅샷·help 캡처(issue5 phase1) 통과 → `get_feedback` 시그니처 확장이 CLI 계약을 건드리지 않는다. 실제 `.mam/jobs` 전후 **차이 0**, 검증 경로 tracked 파일 변화 없음(HEAD `e80186f`), 본 체크아웃 `main 7a4a0c2`.
|
||||
|
||||
## 5. 병합 차단 여부 (항목 2)
|
||||
|
||||
1회차 PASS 를 뒤집을 요소는 없다. 델타는 `get_feedback` 의 기본값 보존 인자 하나, `_subjob_state` 의 두 줄, `STRAY_RULE` 한 문장, 회귀 3건이며, 정정 대상만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`lib.sh:32` `4.7.3`, 오케스트레이터가 4.7.4 로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 K2(`MAM_JOB_TREE_MAX` 초과 시 대기 생략)·K3(`target_not_found` 의 rc 5)·K4(폴링마다 전체 스캔)는 이 델타 범위 밖이라 그대로 열려 있다.
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| L1 | 첫 단어 판정은 에이전트가 자기 사유를 `deadline_timeout`/`agent_blocked` 로 시작해 쓰면 미확인 종결로 본다 — 두 단어는 오케스트레이터 예약어라 실무 충돌은 없다 | `runner.py:696`, §2 표 |
|
||||
|
||||
## 6. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260925T072328Z-2f5853/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # e80186f… / 3501868…
|
||||
# in-process (env -u MAM_VERSION): 임시 등록부에 12가지 종결 사유를 .events.log / 감사 ndjson 으로 심고 _subjob_state 호출 (§2 표)
|
||||
# 되돌린 사본: registry.py·runner.py 를 ed293dc 판으로 → -k "t13 or t14 or t6 or t5" → 4 failed / 3 passed
|
||||
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -p no:cacheprovider -q --basetemp=/tmp/bt tests/test_issue34_job_tree.py) # 20 passed
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,83 @@
|
||||
# Issue #60 리뷰 2차 — opencode 2.x 전용 지원 (job 8a485fa0)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T064723Z-79133f/1`
|
||||
- identity: HEAD `cb5f439cce41a2741a6b9974a308a765e50f00ad`, tree `cfec95403cbde10a6340b9007e1246c83be461cd` — 일치, 작업 트리 clean, `.mam` 없음. base..head 4커밋(a311c23, f0d1c00, 6b4b96f, cb5f439). 판정은 base..head 전체에 대해 내린다.
|
||||
- 1차(6b4b96f)에서 확인한 사항(측정 사실↔구현, 실바이너리 `session delete` 의 자식 삭제·cwd 무관·공유 행 보존, `--standalone` 수명, 1.x 가정 잔존 없음)은 cb5f439 가 건드리지 않은 부분이라 그대로 유효하다. 이번에는 cb5f439 를 중심으로 봤다.
|
||||
- 실제 opencode 는 `HOME`/`TMPDIR` 를 scratchpad 로, `XDG_*` 를 unset 하고 `api --standalone`·`session delete --standalone`·`debug paths` 로만 실행했다. 모델 호출 프롬프트는 보내지 않았다. 매 실험 뒤 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 제외). 실제 경로는 `ls`·`grep`(읽기)으로만 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
1차 지적 5건의 수정은 모두 동작하고, 테스트가 각각을 잡는다. 하지만 **N4 수정(XDG_DATA_HOME 따르기)이 테스트 격리를 깨는 새 회귀를 만들었다(B1).** 테스트 러너 환경에 `XDG_DATA_HOME` 이 있으면 sandbox 테스트가 sandbox 밖의 opencode 데이터 디렉터리(Linux 라면 사용자의 실제 DB)를 대상으로 삼고, 2개 테스트가 실패한다. 사용자가 다음에 Linux 호환성 시험을 할 예정이고 브리프가 실제 opencode 데이터 접근을 금지하므로 차단으로 본다. 수정은 fixture 두 줄이다(아래). 설계 변경은 필요 없다.
|
||||
|
||||
## B1 (차단). XDG_DATA_HOME 이 설정된 러너에서 sandbox 테스트가 sandbox 밖 DB 를 본다
|
||||
|
||||
원인: `opencode.py:87-94` `_data_home` 은 `ctx.home_dir` 이 `expanduser("~")` 와 같으면 호출자의 `XDG_DATA_HOME` 을 쓴다. 테스트 fixture `mam_sandbox`(`tests/conftest.py:259`)와 `home`(`tests/test_issue31_purge_residual.py:85-96`)는 `HOME` 을 tmp 로 바꾸지만 `XDG_DATA_HOME` 은 지우지 않는다(`conftest.py:307` 은 `XDG_CONFIG_HOME` 만 지움). 그래서 sandbox 의 HOME 이 "자기 홈" 으로 판정되어 러너의 `XDG_DATA_HOME/opencode/opencode.db` 가 선택된다. 1차 head 는 `_run` 이 `XDG_DATA_HOME=<home>/.local/share` 로 강제했으므로 이런 일이 없었다.
|
||||
|
||||
재현(검증 경로, `env -i … XDG_DATA_HOME=<scratchpad>/j8a/xdg`):
|
||||
```
|
||||
pytest tests/test_tier2_component.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py -k opencode
|
||||
FAILED tests/test_tier2_component.py::test_comp_opencode_create_and_reconcile_and_stop
|
||||
assert s_after.get("opencode_session_id_own") == parent → None (drift C 가 sandbox DB 가 아니라 XDG 쪽을 봄)
|
||||
FAILED tests/test_issue31_purge_residual.py::test_opencode_purge_leaves_no_rows
|
||||
'<sandbox>/home/.local/share/opencode/opencode.db (session_v2.id: 1 rows)' in [] (residual 이 XDG 쪽 DB 를 스캔)
|
||||
2 failed, 21 passed
|
||||
```
|
||||
XDG 위치에 미리 DB 를 둔 대조 실험에서도 residual 스캔과 purge 의 DB 해석이 그 DB 를 가리켰다. 이 실험에서는 sandbox 세션 id 가 무작위라 지워진 행은 없었다. 그래도 Linux 에서 `XDG_DATA_HOME` 을 둔 사용자가 스위트를 돌리면:
|
||||
- reconcile 은 실제 DB 를 읽고,
|
||||
- purge 는 실제 DB 에 `DELETE … WHERE id=?` 트랜잭션과 가짜 `session delete` 를 실행할 수 있다.
|
||||
- #55 가드(`tests/_agent_home_guard.py:48`)는 `$HOME/.local/share/opencode` 만 감시하므로 이것을 잡지 못한다.
|
||||
|
||||
수정 확인: 사본에서 `conftest.py` 의 `mam_sandbox` 에 `monkeypatch.delenv("XDG_DATA_HOME", raising=False)` 를, issue31 `home` fixture 에 같은 줄을 넣고 같은 조건으로 실행하자 **22 passed**, XDG 위치에 DB 접근 흔적이 없었다. 아래 N2 의 `--version` 로그만 남았다. `_agent_home_guard` 의 감시 대상에 `XDG_DATA_HOME` 쪽 opencode 디렉터리를 넣는 것도 권한다.
|
||||
|
||||
## 1차 지적 수정 확인
|
||||
|
||||
| 지적 | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| codex P2: 자식 세션의 1.x 행 잔존, 부모 residual 이 빔 | `_descendants`(`opencode.py:195-217`)가 삭제 **전에** session_v2·1.x session 의 `parent_id` 를 모든 깊이로 따라가고, `_LEGACY_TABLES` 8개를 부모+자손 id 로 정리한다. residual 은 자손을 `[child session <id>]` 로 보고한다 | **실바이너리 재현**: 임시 HOME 에서 `api --standalone` 으로 P→C→G(3단) + 무관 O 를 만들고, 1.x 형 `session(id,parent_id)`·message·part·todo·session_share 를 덧붙여 어댑터 purge 를 실행. `_descendants(P)==[C,G]`, purged `['opencode session (with its child sessions): P', 'sqlite rows for session: P and 2 child session(s)']`, residual P·C·G 모두 `[]`, O 는 7건 그대로. 순환 방지(`cid != uuid and cid not in found`), 읽기 전용 URI(`quote(abspath)`) 확인 |
|
||||
| codex 주의: 사용자 서비스가 DB 를 잡아 모든 purge 가 exit 4 | `conversation_in_use`(`:240-262`)가 argv 검사 후 lsof 보유자 중 `serve --service` 가 아닌 것만 in-use. lsof/ps 예외는 in-use(fail-closed) | in-use CLI 시뮬레이션(임시 HOME, 가짜 opencode): 보유자 없음 → rc 0. `… serve --service` 보유자 → rc 0. `… serve --stdio --port 0` 보유자 → rc 4. argv 에 id → rc 4. 힌트에 lsof 줄 추가됨 |
|
||||
| N4: XDG_DATA_HOME 무시 | `_data_home` | 제품 동작은 의도대로다(`test_the_data_dir_follows…`). 다만 B1, N1 |
|
||||
| agy: 상주 자식 | `resident_children=('opencode',)` | `stall_watch.background_children`(`:118-136`)는 상주 자식을 건너뛰되 그 자식들은 계속 순회하므로, 서버 아래 도구 프로세스는 여전히 작업으로 잡힌다. 1차에서 실측한 서버 자식 이름(`opencode serve --stdio --port 0`, argv0 basename `opencode`)과 맞음 |
|
||||
| claude N1·N2 | 실바이너리 테스트가 `_bin` 을 `REAL` 로 고정. tier2 가 더 늦은 자식 세션을 시드하고 부모 고정을 단언 | PATH 에 opencode 가 없는 `env -i` 로 `test_real_opencode_2x_contract` 통과(아래 테스트 결과) |
|
||||
|
||||
뮤테이션(git archive 사본, `-k "opencode and not real_opencode"`, issue60·issue31·tier2):
|
||||
|
||||
| 변형 | 실패 |
|
||||
|---|---|
|
||||
| 자손 제거(`ids=[uuid]`) | 1 (`test_purge_takes_the_1x_rows_of_the_child_sessions_too`) |
|
||||
| residual 의 자손 보고 제거 | 2 |
|
||||
| 서비스 제외 제거 | 1 (`test_the_background_service_alone…`) |
|
||||
| XDG 무시 | 1 |
|
||||
| resident `()` | 1 |
|
||||
| reconcile drift C `parent_id IS NULL` 제거 | 1 (tier2 — 1차 N2 해소) |
|
||||
| `_LEGACY_TABLES` 에서 todo·session_share 제거 | 2 |
|
||||
|
||||
## 테스트 실행 (검증 경로, `env -i`, PATH 에 opencode 없음, basetemp 는 scratchpad, `XDG_DATA_HOME` 미설정)
|
||||
|
||||
- 관련 12개 모듈(a4, issue31, issue10_13, issue60, tier1, tier2, issue5_phase0, issue23_24, issue6, c1, issue9, issue30_stall_watch): **473 passed, 1 skipped in 1010 s**, rc 0. skip 은 `/proc` 전용 테스트(macOS). 1차의 N1 이 해소되어 PATH 없이도 실바이너리 계약 테스트가 통과한다. #55 오탐 없음. 실행 뒤 남은 opencode 프로세스 없음.
|
||||
- 같은 테스트를 `XDG_DATA_HOME=<scratchpad>` 로 실행하면 2 failed — B1.
|
||||
|
||||
## 비차단
|
||||
|
||||
**N1. XDG 를 따르게 되면서 status.sh 가 어긋난다.** `status.sh:108`, `:318` 은 여전히 `{home}/.local/share/opencode` 고정이다(reconcile 의 `:917` 폴백도 같으나 `_resolve_db` 가 항상 경로를 돌려줘 닿지 않음). Linux 에서 `XDG_DATA_HOME` 을 둔 사용자라면 reconcile 은 XDG 쪽 DB 에서 id 를 고정하는데 status 는 같은 세션을 `MISSING` 으로 보인다. 표시만의 문제지만 N4 수정과 같은 범위이므로 status 도 어댑터의 `_resolve_db` 를 쓰는 편이 낫다. `auth_ok`·create_session.sh 의 auth.json 검사는 브리프대로 의도적 비변경으로 보았다.
|
||||
|
||||
**N2. (기존, f0d1c00 부터) `test_issue60_opencode_v2.py` 를 import 하기만 해도 실제 opencode 가 실제 HOME 으로 실행된다.** `_real_opencode()`(`:~300`)의 `--version` 호출이 모듈 import 시점에 러너 환경 그대로 돈다. 그 결과 실제 `~/.local/share/opencode/log/opencode.log` 에 `args="[\"--version\"]"` 줄이 붙는다. 읽기로 확인: 2026-10-18T06:46~06:47Z 에 여러 줄. 내 1차 리뷰 실행도 같은 줄을 남겼음을 밝혀 둔다. 데이터 손상은 아니지만 "실제 ~/.local/share/opencode 쓰기 금지" 에 걸리므로, 버전 확인도 임시 HOME/XDG 로 돌리거나 테스트 본문(임시 HOME 설정 뒤)으로 옮기기를 권한다.
|
||||
|
||||
**N3. 서비스 제외의 한계(의도된 설계로 보임).**
|
||||
- 다른 워크스페이스의 MAM opencode 세션(`--standalone` 서버)이 하나라도 떠 있으면, 그 세션과 무관한 purge 도 여전히 exit 4 다. 안전한 쪽의 오탐이다.
|
||||
- 반대로, 사용자가 서비스 모드 TUI 나 웹 UI 로 MAM 세션을 id 없이 열고 있으면 그 사용은 보이지 않는다. MAM 은 `--standalone` 으로만 띄우므로 실사용 위험은 낮다.
|
||||
- Linux 에 lsof 가 없으면 DB 보유자 검사가 통째로 빠진다. base 와 같다.
|
||||
|
||||
## Python 3.9 / bash / Linux
|
||||
|
||||
- 변경 파일은 Python 3.9 로 파싱된다(`/usr/bin/python3` 3.9.6, venv 3.9.6). f-string 안의 따옴표 중첩 없음, `from urllib.parse import quote` 는 표준.
|
||||
- `ps -o command= -p` 는 procps-ng 에서도 지원된다.
|
||||
- resident 테스트의 `exec -a opencode` 는 bash 기능이라 Linux 에서도 동작한다.
|
||||
- 셸 스크립트는 cb5f439 에서 바뀌지 않았다.
|
||||
- `git diff --check` 깨끗.
|
||||
|
||||
## 판정
|
||||
|
||||
B1 은 fixture 두 줄로 고쳐지고 수정 효과도 확인했다. 그러나 Linux 시험 직전에 사용자 실제 데이터 접근 가능성을 만드는 테스트 격리 회귀이므로 차단으로 둔다. 그 밖의 수정은 모두 PASS 수준이다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261013T025132Z-60f239",
|
||||
"base_sha": "ae2813773c973daedd2275cb55ad340a682c3fae",
|
||||
"head_sha": "62da8056680fca939a2aff924037ed48b5f74afa",
|
||||
"tree_sha": "f549246ffbe2d5e58cb7e33c212a383cd429dce5",
|
||||
"review_commit": "62da8056680fca939a2aff924037ed48b5f74afa",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "8b2392ce",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-13T02:56:58Z"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# README.md / README.ko.md 4차 리뷰 — 커밋 62da805 (job 8b2392ce)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261013T025132Z-60f239 / base ae281377 .. head 62da8056 (커밋 1개, 두 README 각 2줄) / tree f549246f
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T025132Z-60f239/1` — HEAD·tree 일치, `git status --porcelain` 0 줄(시작·종료). 본 체크아웃 HEAD 2ccf439(main) 변동 없음.
|
||||
- 방법: 바뀐 두 문장만 코드와 대조. `generate-env.sh`·설치기는 실행하지 않았고, 테스트도 돌리지 않았다(픽스처 집계는 `pytest --co` 에 수집 전용 플러그인을 얹어 `fixturenames` 만 셌다; `env -i`, basetemp 는 scratchpad). 실제 서버·세션·`.mam`·`.mam.env`·에이전트 홈 미접촉.
|
||||
|
||||
## 1. 두 문장의 코드 대조
|
||||
|
||||
| 문장 (README.md / README.ko.md 같은 줄) | 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| **:41** `generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만들고, `--force` 는 `.mam.env.bak` 을 남기고 덮어씀 | `deploy/generate-env.sh:54-64`: `[ -f "$DST" ] && [ "$FORCE" != "1" ]` → "no-op … (use --force to overwrite)" `exit 0`; `[ -f "$DST" ] && [ "$FORCE" = "1" ]` → `cp -p "$DST" "$DST.bak"`; 그 뒤 `cp "$SRC" "$DST"`. 머리 주석(`:5-7`)과도 일치 | ✓ |
|
||||
| **:175** 모든 테스트는 임시 잡 레지스트리·감사 로그; 약 3분의 1 이 임시 HOME 샌드박스 픽스처, 그보다 적은 수가 mock herdr | 이 head 에서 수집 1901 개 중 `fixturenames` 에 `mam_sandbox` 포함 **575 (30 %)**, `mock_herdr` 포함 **186 (9 %)**(`mock_agents` 125). grok 집계(1708 중 573/196)와 모수만 다르고 비율은 같다. "약 3분의 1" 과 "그보다 적은 수" 모두 맞음. 전체 테스트의 임시 레지스트리·로그는 `conftest.py:170-181` 의 autouse 픽스처(3차에서 확인) | ✓ |
|
||||
|
||||
## 2. 두 언어의 일치
|
||||
- 두 문장 모두 영문·한국어가 같은 조건·같은 수치를 말한다(없을 때 생성 / `--force` 는 `.bak` 남기고 덮어씀; 약 3분의 1 / 그보다 적은 수).
|
||||
- 구조(head): 제목 18/18, 표 행 17/17, 코드 블록 7/7(번역 주석 블록 외 동일), 숫자 집합 동일, 백틱 토큰 차이는 `MULTI_AGENT_RULES.md` ↔ `.ko.md` 뿐.
|
||||
|
||||
## 결론
|
||||
grok 21063f28 의 두 지적이 코드와 맞게 고쳐졌고 두 언어가 같다. 앞 라운드에서 내가 남겼던 `generate-env.sh` 메모도 이 커밋으로 해소됐다. 남은 지적 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261003T230658Z-e7866a",
|
||||
"base_sha": "8d26106195a0a24621f140e76389d206332f84c0",
|
||||
"head_sha": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
|
||||
"tree_sha": "a0f1ffede44be7c86267a56b25871e1d081feafb",
|
||||
"review_commit": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "95b63371",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-03T23:35:18Z"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# 리뷰 리포트 — Job 95b63371 (PR #46 2회차 델타, 8d26106..17eba09)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261003T230658Z-e7866a / base 8d26106 / head 17eba09 / tree a0f1ffe — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 17eba09 한 개. `git diff --stat 8d26106 17eba09 -- . ':!*.md' ':!tests'` 결과가 비어 있음 → 제품 코드·버전 변경 없음(항목 4 ✓)
|
||||
|
||||
## 결론 요약
|
||||
codex R1~R3, claude O1·O4 가 모두 닫혔다. 차단 결함 없음. 비차단 관찰 2건(§5).
|
||||
|
||||
## 1. 지적 사항 종결 여부
|
||||
| 지적 | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| codex R1 (attach 대상 서버) | resume SKILL.md Case 3·Workflow 설명·When NOT 이 `herdr --session <server> agent attach <name>` 로 서버를 명시, Verification 이 행에서 SERVER 를 읽어 `--session "$SERVER"` 사용 | ✓ 아래 §2 |
|
||||
| codex R2 (--dry-run 문구) | create·resume 옵션 표: "no spawn and no registry (YAML/DB) update; 앞선 검사는 돌고 macOS 에서 quarantine 속성을 지운다" | ✓ create_session.sh:196 xattr → :280 dry-run 종료, spawn() 은 :264-274 정의만, YAML 쓰기 :305·:491 은 그 뒤. resume_session.sh:169 xattr → :225 dry-run 종료, spawn :237 |
|
||||
| codex R3 (삭제 본문 복귀 가드) | test_no_deleted_body_came_back 이 DATA["blocks"] 를 읽지 않고 현재 모든 블록을 removed+superseded 의 base_sha / base_fingerprint 와 비교 | ✓ §3 탐침 |
|
||||
| claude O1 (create:263-266 누락) | superseded 에 class c / invocation, base_sha ceb96a8c1293 으로 기록 | ✓ base 45d1621 에서 parse_blocks 로 다시 계산한 sha·fingerprint 일치 (removed 5 + superseded 1 모두 True/True) |
|
||||
| claude O4 (표 문구) | create 42 에 `MAM_KILL_ON_READY_TIMEOUT=1` 경우, stop 0 에 "--purge-conversation 에서는 no-op 아님" | ✓ create_session.sh:388-398, stop_session.sh:185 와 일치 |
|
||||
|
||||
## 2. R1 — 안내 명령이 실제로 격리 서버로 가는가
|
||||
herdr 서버를 건드리지 않도록 `--help` 의 Logs 경로(세션 해석 결과)만으로 확인했다(`env -i` 로 상속 변수 제거):
|
||||
| 호출 | 해석된 세션 디렉터리 |
|
||||
|---|---|
|
||||
| `herdr --help` | ~/.config/herdr/ (기본 서버) |
|
||||
| `herdr --session zz-probe --help` | ~/.config/herdr/sessions/zz-probe/ |
|
||||
| `herdr --session default --help` | ~/.config/herdr/ (기본 서버와 동일) |
|
||||
| `HERDR_SESSION=zz-probe herdr --help` | sessions/zz-probe/ |
|
||||
| `HERDR_SESSION_NAME=zz-probe herdr --help` | ~/.config/herdr/ (무시됨) |
|
||||
| `herdr --session "" --help` | `error: session name cannot be empty` |
|
||||
(탐침 뒤 ~/.config/herdr/sessions 에 zz-probe·default 디렉터리 생성 없음 확인.)
|
||||
|
||||
- 따라서 `herdr --session <row.herdr_session> agent attach` 는 그 격리 서버로 간다. 행이 `herdr_session: default` 여도 `--session default` 가 기본 서버로 해석되므로 맞다. lib.sh shim(`_real_herdr`, lib.sh:255-268)이 default 일 때 `--session` 을 빼는 것과 결과가 같다.
|
||||
- resume_session.sh 가 export 하는 것은 `HERDR_SESSION_NAME` 이고 native herdr 는 이를 읽지 않으므로(위 표), 서버 명시가 필요하다는 수정 근거가 맞다.
|
||||
- Verification SERVER 계산: 행에 herdr_session 없음 → `'default'` → 기본 서버(올바름). 행 자체가 없음 → python IndexError, SERVER="" → herdr 가 "session name cannot be empty" 로 즉시 실패. 검증 블록으로서는 조용히 엉뚱한 서버를 보는 것보다 낫다. 행 기록자는 create_session.sh:524-525, update_yaml_resumed.sh:140-141·155-156 에서 herdr_session 과 herdr_server 를 항상 같이 쓰므로 herdr_session 만 읽어도 된다.
|
||||
|
||||
## 3. R3 가드 — 견고성과 엄격성(스크래치 사본 변이 시험)
|
||||
| 변이 | 새 테스트(17eba09) | 이전 테스트(8d26106) |
|
||||
|---|---|---|
|
||||
| 기준 | 31 passed / 1 skipped | — |
|
||||
| codex 탐침: base resume Workflow 본문(45d1621 :59-118)을 목록 항목 안에 2칸 들여 다시 넣고 인벤토리에 class c 로 등록 | **1 failed** (test_no_deleted_body_came_back) | 28 passed (못 잡음) |
|
||||
| 정상 편집: create 에 새로운(다른) 호출 예시 추가 | R3 가드 통과, 인벤토리 테스트만 실패(의도된 동작) | — |
|
||||
| 삭제 본문 + 주석 한 줄을 앞에 붙여 다시 넣음 | R3 가드 통과(인벤토리 테스트만 실패) | — |
|
||||
- fingerprint(공백 접기 후 sha256 12자)는 재들여쓰기·줄 바꿈 변화에 강하고, 정확 일치만 보므로 다른 예시를 막지 않는다. 정상 편집을 막을 정도로 엄격하지 않다.
|
||||
- test_a_returned_body_is_caught_reindented_and_with_the_inventory_updated 는 가드 함수 자체를 현재 stop 블록으로 검사한다(정확 sha + 재들여쓰기 fingerprint) — 유효.
|
||||
|
||||
## 4. 인벤토리 재생성
|
||||
8d26106 과 17eba09 의 skill_code_blocks.json 비교: blocks 33개의 (skill, class, kind) 순서가 **동일**. 바뀐 값은 resume Verification 블록(start 103)의 sha·end(124→128) 뿐이고, removed 5개는 base_fingerprint 추가만, entry_points 동일, superseded 신설. 분류 변경 없음(항목 3 ✓).
|
||||
|
||||
## 5. 비차단 관찰
|
||||
- **N1 가드의 한계**: R3 가드는 블록 본문 전체 일치만 본다. 삭제된 본문 앞에 한 줄만 붙여도(§3 셋째 행) 인벤토리를 갱신하면 통과한다. 이번 지적(R3)의 범위인 "인벤토리 재등록으로 숨기기"는 막았으므로 차단은 아니며, 필요하면 후속에서 줄 단위 포함 비율로 넓힐 수 있다.
|
||||
- **N2 문구 정밀도**: resume SKILL.md Workflow 설명의 "the native `herdr` binary does not route on environment variables" 는 정확히는 "`HERDR_SESSION_NAME` 으로는 라우팅하지 않는다"이다 — native herdr 는 `HERDR_SESSION` 을 읽는다(§2 표). 특히 herdr 패널 안에서는 `HERDR_SESSION` 이 호출자 서버로 설정되어 있어, 서버를 빼면 호출자 서버로 간다. 결론(`--session` 명시)은 그대로 옳다.
|
||||
- 미반영으로 명시된 codex C2, claude O2·O3 는 후속 범위로 동의한다.
|
||||
|
||||
## 6. 테스트
|
||||
검증 경로에서 지정 6개 파일: **132 passed / 0 failed / 1 skipped** (300 s, rc 0). skip 은 test_claude_skills_link_resolves_to_agents_skills(워크트리, 정상). test_issue42_skill_blocks.py 단독 31 passed / 1 skipped. MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 +0.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261005T085016Z-c0f6eb",
|
||||
"base_sha": "e8d9151226d8139c7b5688e83d035a3a747aeb2d",
|
||||
"head_sha": "ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9",
|
||||
"tree_sha": "f49ffdc8e95dc316f752fd81b2d55a0efddf1a12",
|
||||
"review_commit": "ab93776d6d1b8ca534c14b6be53f61dc3a0fa2a9",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "ab18016d",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-05T09:05:24Z"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# 리뷰 리포트 — Job ab18016d (PR #48 3회차 델타, e8d9151..ab93776)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261005T085016Z-c0f6eb / base e8d9151 / head ab93776 / tree f49ffdc — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위(항목 4): lib_py/herdr_config.py(+25/−2), lib_py/herdr_restore.py(+10/−2), 테스트 2개. bash·문서·버전 변경 없음. 새 판정은 모두 Python 에 있다(#17).
|
||||
|
||||
## 결론 요약
|
||||
codex R1~R3 가 닫혔다. R1 의 "MINIMAL 무검사 사용" 판단에 동의한다. `_same_doc` 은 nan 외에는 이전 `==` 와 같거나 더 엄격하며, 그 엄격함이 오탐(불필요한 MINIMAL 폴백)을 만들지 않음을 확인했다. 차단 결함 없음.
|
||||
|
||||
## 1. R1~R3 종결
|
||||
| 지적 (codex 2회차) | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| R1 `config check` 를 실행 못 하면(timeout·OSError) 병합 결과를 검사된 것으로 넘김 | herdr_config.py:245-258 — 병합 본문이면 경고 후 `continue` 로 MINIMAL 시도, MINIMAL 의 검사도 실행 못 하면 MINIMAL 을 경고와 함께 사용 | test_an_unrunnable_check_never_hands_over_an_unchecked_merge[timeout/oserror]: 스키마 오류가 든 사용자 설정이 생성 파일에 들어가지 않고 MINIMAL + "not using an unchecked merge" 경고, 원본 불변. 이전 제품 코드에서 2건 실패 |
|
||||
| R2 `_shell_in_foreground` 가 0/0·False/False·""/""·-1/-1 에서 True | herdr_restore.py:44-53 — 두 값이 bool 아닌 양의 int 일 때만 비교 | test_issue45_restore_unit.py: zero·false·empty·negative·null·string·true·float 모두 닫지 않음, 정상 pid 같음 → 닫음, 다름 → 둠. 이전 코드에서 7건 실패(null 은 이전에도 통과) |
|
||||
| R3 재파싱 `==` 가 nan≠nan 이라 nan 하나로 사용자 설정 전체를 버림 | herdr_config.py:140-150 `_same_doc`, :171 에서 사용 | test_a_nan_value_does_not_drop_the_other_settings[top/array/nested]: 경고 없이 설정 유지. 이전 코드에서 3건 실패 |
|
||||
이전 제품 코드(e8d9151 의 두 파일)로 되돌린 스크래치 사본에서 새 테스트는 **12 failed / 55 passed**(3.11), 현재 코드에서 **67 passed**.
|
||||
|
||||
## 2. R1 판단 — MINIMAL 무검사 사용 vs 경로 반환 안 함
|
||||
동의한다. 근거:
|
||||
- 경로를 반환하지 않으면 lib.sh:241-245 가 `HERDR_CONFIG_PATH` 없이 서버를 띄운다 → herdr 는 사용자 설정(복원 기본 켜짐)을 읽어 **#45 가 확실히 재발**한다.
|
||||
- MINIMAL 을 넘기면, herdr 가 그것을 받아들이는 경우(고정 2줄, 실제 herdr 0.9.1 에서 test_real_herdr_accepts_every_result 가 확인) 복원이 꺼지고, 만약 거부하더라도 herdr 는 기본값으로 돌아 결과는 "경로 없음"과 같은 복원 켜짐이다. 즉 MINIMAL 은 복원 측면에서 "경로 없음"보다 나쁜 경우가 없고, 잃는 것은 이미 경고되는 사용자 설정뿐이다.
|
||||
- 검사가 **실행되어 거부**한 경우는 여전히 None(:259-262)이다. "검사 불가"와 "검사 거부"를 다르게 다루는 것이 일관적이다(거부는 herdr 가 그 파일을 버린다는 증거).
|
||||
- 알 수 없는 nonzero 종료를 통과로 두는 것(:178)도 기존 근거(무해한 경고에도 nonzero)와 같고 이번 변경 범위 밖이다.
|
||||
|
||||
## 3. `_same_doc` 의 의미 (항목 3)
|
||||
3.11 에서 이전 `==` 와 직접 비교:
|
||||
| 경우 | 이전 == | _same_doc |
|
||||
|---|---|---|
|
||||
| nan / nan, 중첩 nan | False | **True** (의도된 변경) |
|
||||
| 1 / 1.0, True / 1, 0 / False | True | **False** (더 엄격) |
|
||||
| -0.0 / 0.0, inf / inf, 같은 time, 같은 문자열 | True | True |
|
||||
| date / datetime | False | False |
|
||||
| [1,2] / [2,1] (배열 순서) | False | False |
|
||||
| dict 키 순서만 다름, 표 배열 [{…}] | True | True |
|
||||
- 더 엄격해진 int/float/bool 구분은 직렬화가 타입을 보존하는 한 오탐을 만들지 않는다: dumps(:107-126)는 int→정수, float→repr(소수점·지수 유지), bool→true/false 로 쓴다. 날짜·오프셋 datetime·소수 초 local time·표 배열·inf·nan·−nan·따옴표 점 키·1e100 이 든 문서로 server_config_text 를 돌려 **설정 유지, 경고 없음**을 확인했다. 오히려 왕복에서 1 이 1.0 으로 바뀌는 것 같은 결함은 이제 잡힌다.
|
||||
- 참고: `-nan` 은 `nan` 으로 쓰여 부호가 사라진다(nan 끼리 같다고 보므로 통과). herdr 설정에서 nan 부호가 의미를 가질 일은 없어 무해하다.
|
||||
|
||||
## 4. 테스트 실행
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| 지정 4개 파일 (pr-48 .venv, Python 3.9, 실제 herdr 포함) | **53 passed / 34 skipped / 0 failed** (76 s). skip = tomllib 전용; test_issue45_restore 는 skip 0 |
|
||||
| herdr_config + restore_unit (지정 venv311, Python 3.11.15) | **67 passed** |
|
||||
| 같은 테스트, 이전 제품 코드 사본 | 12 failed / 55 passed |
|
||||
격리: 실행 전후 herdr 프로세스는 기존 multi-agent-mux 서버·attach·오케스트레이터 `agent wait` 뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` 목록·config.toml md5·sessions 불변. MQTT_* unset, .mam.env 미export.
|
||||
|
||||
## 5. 비차단 관찰
|
||||
- R1 테스트는 두 검사 모두 실행 불가인 경우만 다룬다. "병합 검사는 실행 불가, MINIMAL 검사는 실행되어 통과" 경로(:256 → 다음 반복의 :258)는 코드상 자명하지만 별도 테스트는 없다.
|
||||
- 2회차의 VERSIONS 4.8.5 항목 1 문장 순서 지적(N1)은 이번 델타에 포함되지 않았다(문서 변경 없음).
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,176 @@
|
||||
# 🔍 Review Report — PR #35 병합 전 독립 리뷰 (job `adc0ff3c`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-23
|
||||
- **대상**: PR #35 `fix/issue-23-24` **@ `f4ba205`**, base `main 1e78dae` (v4.7.1). 13커밋, 22파일 `+2197/−118`, `git diff --check` 클린.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T142448Z-609954/1`. **identity 확인**: `HEAD = f4ba205c…fd2f`, `HEAD^{tree} = 88cb4f34…f181` — 브리프의 `head`·`tree` 와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-35`)·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4 이름)에서만, pytest 는 `AGENT_PYTHON_BIN=<제어 루트 .venv>` + `--basetemp=<검증 경로 밖>` 으로 실행. 실제 herdr 서버·에이전트 세션·`.mam`·데몬 조작 없음(이 pane 의 herdr 환경변수를 **읽기만** 했다), push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**병합을 막을 결함은 없다.** 세 핵심 규칙 — 교차 서버 판정, "이번 dispatch 의 started 만 증거", kill 결과 확인 롤백 — 을 각각 독립 실험으로 재현·확인했고, 새 테스트는 실제 `registry.py`/`publish_event.py` 를 쓰며 홈·브로커·실제 herdr 에 닿지 않는다. 비차단 지적 6건(F1–F6)을 남긴다. 그중 F1 은 **브리프의 codex 수락 키 서술과 코드가 다르다**는 점이라 오케스트레이터가 확인할 필요가 있다.
|
||||
|
||||
## 1. #24 시작 다이얼로그 (항목 1)
|
||||
|
||||
### 1-1. 어댑터 시그니처·수락 키
|
||||
|
||||
`python -m lib_py.agents facts <agent>` 가 실제로 내놓는 값(검증 경로에서 실행):
|
||||
|
||||
| 에이전트 | `MAM_STARTUP_DIALOGS` | 강한 준비 토큰 |
|
||||
|---|---|---|
|
||||
| claude | 기존 사다리 5개(trust Enter / `Yes, try it` Escape / `Yes, proceed` Down+Enter / `Resuming…` Enter / `Press Enter…` Enter)가 그대로 옮겨짐 | `Anthropic\|Assistant\|…\|Haiku` |
|
||||
| agy | `Do you trust the contents of this project\|Yes, I trust this folder` → `Enter`, trust | `Antigravity` |
|
||||
| grok | `Do you trust the contents of this directory` → `y`, trust | `Grok\|xAI\|Assistant\|❯\|>>>` |
|
||||
| codex | `Do you trust…\|1\. Yes, continue` → **`Enter`**, trust · `1\. Update now\|Skip until next version` → **`2`**, notice | `Codex\|Ask Codex to do anything` |
|
||||
| opencode·hermes | `[]` (다이얼로그 없음) | 있음 → 핸들러가 배너에서 즉시 반환 |
|
||||
|
||||
**F1 (확인 요망, 비차단)**: 브리프는 codex 수락 키를 "`1`+Enter, 업데이트 안내 `3`+Enter" 로 적었으나 코드는 **Enter 단독**(1번 항목이 미리 선택돼 있다는 주석)과 **`2`**("2 = Skip" 이라는 관찰 기록, 2026-09-22) 다. 어느 쪽이 실제 화면인지는 이 리뷰에서 재현할 수 없다(실제 codex 를 띄우는 것은 금지). PR 본문은 codex 를 포함한 4개 에이전트의 실제 herdr 시험이 사람 개입 없이 `started`(토큰 포함)→`completed` 까지 갔다고 기록하므로 **코드 쪽이 실측**으로 보이지만, 브리프 작성자의 관찰과 다르므로 병합 전에 한 번 맞춰 볼 것을 권한다. `_MAM_READY_TOKENS_CLAUDE` 상수는 제거되고 어댑터의 `strong_ready_tokens` 로 대체됐다(값 동일).
|
||||
|
||||
### 1-2. 준비 토큰으로 수락을 판정하는 이유
|
||||
|
||||
`handle_startup_dialogs`(`lib.sh:2979-`)는 시그니처가 화면에 남아 있어도 같은 인덱스에 대해 `MAM_DIALOG_RESEND_S`(기본 5 s) 안에는 재전송하지 않고(`_mam_dialog_pick` 의 cooldown), 반환 조건은 **오직 준비 토큰**이다. `--source visible` 스냅샷이 수락된 다이얼로그를 한동안 계속 보여 준다는 #24 의 관찰과 맞물려 타당하다 — 옛 코드는 매 폴링마다 Enter 를 다시 보내 이미 넘어간 TUI 에 입력을 흘렸다. `test_c1_tui_readiness.py::test_c1b` 의 단언이 `==30` 에서 `1 ≤ n ≤ 30` 으로 바뀐 것도 같은 이유이며, "한 프레임에 두 번 보내지 않는다"는 원래 가드는 그대로다.
|
||||
|
||||
### 1-3. `--no-auto-trust` / `MAM_AUTO_TRUST=0`
|
||||
|
||||
`_mam_dialog_pick` 은 `kind == "trust"` 이고 auto_trust 가 꺼져 있으면 `skip|idx|` 를 내고, 핸들러는 경고만 남긴다(cooldown 마다 1회). `notice` 는 항상 답한다. create(`create_session.sh:86-94`)와 resume(`resume_session.sh:43-57`)이 **같은 스위치·같은 env 변수(`MAM_AUTO_TRUST`)·같은 호출(`handle_startup_dialogs "$SESSION" N "$AGENT"`)** 을 쓴다. S6/R3 가 고정.
|
||||
|
||||
### 1-4. 자동 수락의 적용 범위(보안)
|
||||
|
||||
pane 은 `new-session -c "$WORKSPACE"` 로 뜨고 신뢰 창은 그 cwd 에 대한 것이므로 실질적으로 `--workspace` 에만 적용된다. 다만 시그니처 정규식은 **디렉터리 이름을 검사하지 않는다** — 화면에 어떤 경로가 적혀 있든 매치되면 수락한다(F5). 시작 직후의 신뢰 창이 다른 디렉터리를 가리킬 경로는 없어 실무 위험은 낮다.
|
||||
|
||||
## 2. #23 교차 서버 스코프 (항목 2)
|
||||
|
||||
`_herdr_caller_server` / `_herdr_call_is_cross_server`(`lib.sh:1403-1447`)를 lib.sh 에서 함수만 추출해 `env -i` 로 9가지 조합을 돌렸다(전부 기대와 일치):
|
||||
|
||||
| `HERDR_SOCKET_PATH` | `HERDR_SESSION` | 대상 | 판정 |
|
||||
|---|---|---|---|
|
||||
| `~/.config/herdr/herdr.sock` | – | `mam` | caller=`default`, **CROSS** (보고된 #23 시나리오) |
|
||||
| 〃 | – | (없음) / `default` | same (앰비언트 sentinel) |
|
||||
| `~/.config/herdr/sessions/mam/herdr.sock` | – | `mam` | caller=`mam`, same → **pane 배치·workspace 패킹 유지** |
|
||||
| 〃 | – | `other` | CROSS |
|
||||
| 〃 | `orch` | `mam` | caller=`orch`(HERDR_SESSION 우선), CROSS |
|
||||
| `/other/layout/herdr.sock` / 비어 있음 | – | `mam` | caller="" → same (pane 밖: 명시 스코프 존중) |
|
||||
| `…/sessions/a b/herdr.sock` | – | `mam` | caller=`a b`, CROSS (공백 안전) |
|
||||
|
||||
- 제거는 `mam_herdr` 안에서 **교차일 때만** `env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID` 로 이루어지고(`lib.sh:1449-1461`), argv 배열 + `if` 로 판정하므로 `set -e` 호출자를 중단시키지 않는다(S12).
|
||||
- `lib_py/loop/watcher.py::caller_server/scoped_env` 는 같은 규칙의 파이썬 쌍둥이다. 같은 5가지 입력으로 돌려 bash 와 **동일 결과**를 확인했다(같은 서버·pane 밖·`default`·`None` 대상은 ids 유지, 교차만 제거).
|
||||
- **실제 레이아웃 근거**: 이 pane(herdr **0.9.1**)의 환경은 `HERDR_SOCKET_PATH=~/.config/herdr/sessions/multi-agent-mux/herdr.sock`, `HERDR_SESSION=multi-agent-mux` 였다 — 규칙이 전제하는 `sessions/<name>/herdr.sock` 형태와 일치하고, `HERDR_SESSION` 도 export 된다. 0.8.2 의 "기본 서버 pane 에는 `HERDR_SESSION` 이 없다"는 전제는 PR 의 실측 기록(코드 주석)에 의존하며 여기서 재현할 수 없다. 두 경우 모두 소켓 경로 규칙이 뒷받침하므로 어느 버전이든 판정이 성립한다.
|
||||
- 인식되지 않는 레이아웃은 ""(= pane 밖) 으로 떨어져 **오늘의 동작을 유지**한다. 잘못된 제거보다 안전한 쪽이다.
|
||||
|
||||
## 3. #23 온보딩 전달 판정 (항목 3)
|
||||
|
||||
### 3-1. 공유
|
||||
|
||||
`delegate_publish_cmd`·`delegate_job_status`·`delegate_started_recorded`·`delegate_wait_started` 가 `lib.sh:2189-2313` 에 하나씩 있고, create 온보딩(`create_session.sh:622-680`)과 래퍼 `_wait_started`(`multi-agent-mux-delegate-job:647-668`)가 **같은 `delegate_started_recorded`** 를 부른다. 래퍼는 `MAM_JOBS_DIR="$REGISTRY_DIR"` 와 `LOGS_DIR_ABS` 를 넘겨 자기 레지스트리·감사 루트를 그대로 쓴다(`:191`,`:659`). 옛 `_disk_status != pending → 성공` 우회와 구독자 콘솔 grep 은 **삭제**됐다(diff 로 확인).
|
||||
|
||||
### 3-2. 증거 규칙 실측
|
||||
|
||||
`delegate_started_recorded` 만 추출해 가짜 `events.log`/`events.ndjson` 으로 10가지를 돌렸다(전부 기대와 일치):
|
||||
|
||||
| # | 상태 | 토큰 요구 | 결과 |
|
||||
|---|---|---|---|
|
||||
| 1 | 파일 없음 | 있음 | NOT |
|
||||
| 2 | 이전 dispatch 토큰의 `started` | 있음 | **NOT** (R1: 이전 시도의 증거 거부) |
|
||||
| 3 | 〃 | 없음 | recorded (토큰 없으면 종전대로) |
|
||||
| 4 | 이번 토큰의 `started` | 있음 | recorded |
|
||||
| 5 | 감사 기록 `dispatch_verified:true`, **fingerprint 없음** | 있음 | **NOT** (신원 없는 과거 기록 fail closed) |
|
||||
| 6 | 〃 | 없음 | recorded |
|
||||
| 7 | 이전 시도의 fingerprint | 있음 | NOT |
|
||||
| 8 | 이번 토큰의 fingerprint | 있음 | recorded |
|
||||
| 9 | fingerprint 맞지만 `dispatch_verified:false` | 있음 | NOT |
|
||||
| 10 | 깨진 줄 + `progress` 만 | 있음 | NOT (JSON 파싱, 패턴 매칭 아님) |
|
||||
|
||||
fingerprint 는 `sha256(token)` 으로 구독자(`job_subscriber.py:303-306`)와 **같은 해시**임을 확인했다. 레지스트리 `status` 는 어느 경로에서도 증거로 쓰이지 않는다.
|
||||
|
||||
### 3-3. 종료된 잡·#18 정합
|
||||
|
||||
- create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다(`:649-657` 주석과 코드; S7). 미확인 시 `delegate_job_status` 가 `completed|error|cancelled` 면 **덮어쓰지 않고** 경고만 한다(`:674-679`; S7m).
|
||||
- 대기 시간은 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec`(기본 180) 순이다 — #18 의 T=180 과 같은 값이고, heartbeat 계약은 `started --heartbeat 30` 명령을 `delegate_publish_cmd` 가 래퍼와 **인자 단위로 동일**하게 만든다(S7l).
|
||||
- `inject_instructions`(= `send_keys_safe`) 의 rc 4 는 세 자리 모두 "전달 미상"(`lib.sh:2746` busy 초과, `:2810` delivery unknown, `:2883` Enter 미수락)이라 `started` 대기로 판정을 넘기는 것이 맞고, rc 1(quiesce 실패)·2·3(다이얼로그 차단)은 즉시 실패로 남는다(S8/S8b).
|
||||
|
||||
## 4. #23 실패 처리 (항목 4)
|
||||
|
||||
- `cleanup_herdr_on_error`(`create_session.sh:318-346`): `kill-session` 의 rc 를 버리지 않고 받은 뒤 `has-session` 으로 재확인, 살아 있으면 행을 `running` + `last_visible_status: rollback-failed` + `delegate_job_id: null` 로 표시하고 **exit 43**. bash 3.2 에서 EXIT trap 안의 `exit 43` 이 원래 종료 코드(1)를 덮어씀을 직접 확인했다(`rc=43`). 상태값은 `atomic_yaml.py:18` 의 어휘(`running/terminated/archived/stopped`) 안이고 `last_visible_status` 는 reconcile 이 이미 쓰는 필드다.
|
||||
- 온보딩 미확인: 세션 보존 + 잡 `error`(이미 종료된 잡은 제외) + 경고 2줄, `ROW_APPENDED` 뒤라 YAML 행은 유효한 `running` 이다. 실패한 잡은 `JOB_TERMINALIZED=1` 로 표시돼 트랩이 `cancelled` 를 겹쳐 발행하지 않는다.
|
||||
- create SKILL.md(`:101-131`)가 이 규칙(started 기반 판정, rc 4 경고, 세션 보존, `MAM_ONBOARD_ACK_S`, exit 42/43 과 행 상태)을 정확히 서술한다.
|
||||
|
||||
## 5. 기존 테스트 조정 6파일 (항목 5)
|
||||
|
||||
| 파일 | 변경 | 판정 |
|
||||
|---|---|---|
|
||||
| `conftest.py` | `mam_sandbox` 에 `MAM_ONBOARD_ACK_S=3` 추가 | 타당. 샌드박스에는 `started` 를 발행할 실제 에이전트가 없어 이것이 없으면 create 테스트마다 180 s 를 소모한다. create 전용 변수라 래퍼의 `MAM_INJECT_ACK_GRACE_S` 를 단언하는 테스트와 간섭하지 않는다 |
|
||||
| `test_b19_*` | `grep -q 'Yes, try it'` 가 lib.sh 에 있는지 → claude 어댑터의 `('Yes, try it', ['Escape'], 'notice')` 로 위치 이동 | 토큰 목록 두 단언은 그대로. 검증 대상이 이사한 것뿐 |
|
||||
| `test_c1_tui_readiness.py` | `KEYS_COUNT==30` → `1 ≤ n ≤ 30`, `custom` 에이전트 → `claude` | §1-2 참조. cooldown 도입으로 "매 반복 1회" 가 "프레임당 최대 1회" 로 바뀌었고 원래 가드(2회 금지)는 유지·강화. `custom` 은 어댑터가 없어 다이얼로그 목록이 비므로 이 시험에는 `claude` 가 맞다 |
|
||||
| `test_issue14_delivery_ack.py` | 레지스트리 상태만 `running` 으로 flip 하던 "ack" 를 **실제 `publish_event.py` + dispatch 토큰의 `started`** 로 교체 | **강화**. 옛 시험은 PR 이 없앤 "상태만 보고 성공" 우회를 통과 조건으로 쓰고 있었다. 새 시험은 로컬 `events.log` 에 토큰이 실린 줄까지 단언한다 |
|
||||
| `test_issue18_liveness_contract.py` | acceptance5 의 배달 단언을 `recorded 'started'` 하나로 좁힘 | 계약상 옳다(레지스트리 상태는 증거가 아님). 다만 "브로커가 retained `completed` 만 배달" 한 경우가 이제 **실패**한다 — `_skip_if_broker_did_not_deliver` 는 그 잡의 어떤 이벤트도 못 받았을 때만 skip 하므로. 공개 브로커에서 flake 가 늘 수 있는 자리(F4) |
|
||||
| `test_issue5_phase2a_loop.py` | G9 가드가 코드만 읽도록(`ast` 로 docstring **노드**만 제거, 주석은 unparse 에서 사라짐) + `g9b` 자체 검증 | 약화 아님. 호출 형태(`subprocess` argv 의 `lib.sh` 경로·`source`, 식별자)는 여전히 코드 텍스트에 있어 잡히고, `g9b` 가 "같은 줄의 코드"(R2) 네 가지까지 고정한다. `ast.unparse` 는 3.9+ — venv 3.9.6 이라 통과 |
|
||||
|
||||
## 7. 문서 (항목 7)
|
||||
|
||||
- create SKILL.md `:101-131`: started 기반 판정·rc 4 경고·세션 보존·`MAM_ONBOARD_ACK_S`·`--no-auto-trust`(codex 의 로컬 설정 로드 위험 명시)·`MAM_DIALOG_RESEND_S`·exit 42/43 과 행 상태가 코드와 일치한다. resume SKILL.md 는 에이전트 분기 `case` 를 없애고 같은 호출로 바꿨다.
|
||||
- **F2 (비차단, 문서 누락)**: 감사 기록의 새 필드 `dispatch_verified`·`dispatch_fingerprint`·`attempt_id` 는 `MESSAGING.md`·delegate-job `SKILL.md` **어디에도 없다**(전수 grep 0건). 두 문서는 `events.ndjson` 을 단계 수준으로만 서술해 필드 목록 자체가 없으므로 모순은 없지만, "래퍼와 create 가 무엇을 증거로 읽는가"가 이번 PR 의 핵심이라 한 문단은 있어야 한다.
|
||||
- **F3 (비차단, 문구 정밀도)**: SKILL.md 는 `MAM_ONBOARD_ACK_S` 가 래퍼의 `MAM_INJECT_ACK_GRACE_S` 와 "별개" 라고만 적었지만 코드의 우선순위는 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec` 이다(`create_session.sh:658`). 즉 래퍼 변수도 create 의 대기에 **2순위로 영향**을 준다.
|
||||
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`, 10개)라 자동 동기화된다. 검증 경로(워크트리)에는 존재하지 않는다 — 배포 사본 정합 문제 없음.
|
||||
|
||||
## 6. 테스트 (항목 6)
|
||||
|
||||
### 6-1. 검증 경로에서의 실행
|
||||
|
||||
```
|
||||
cd <검증 경로> && AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python> PYTHONDONTWRITEBYTECODE=1 <같은 인터프리터> -m pytest \
|
||||
-p no:cacheprovider -q -rfEs --basetemp=<검증 경로 밖> \
|
||||
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_b19_headless_reconcile_fixes.py \
|
||||
tests/test_c1_tui_readiness.py tests/test_issue14_delivery_ack.py tests/test_issue18_liveness_contract.py \
|
||||
tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py tests/test_loop_cli.py
|
||||
→ 273 passed in 700.58s (0:11:40) failed 0 · errors 0 · skipped 0
|
||||
```
|
||||
|
||||
새 파일 2개(73건)와 조정된 6파일, 그리고 셸 shim 계약·loop CLI 까지 모두 통과. 브로커 의존 시험(issue14·issue18)도 이번 실행에서는 flake 없이 통과했다.
|
||||
|
||||
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 PR 이 바꾼 소스 12파일(lib.sh·어댑터 5·`__main__.py`·`job_subscriber.py`·`watcher.py`·create/resume 스크립트·래퍼)만 base `1e78dae` 판으로 되돌리고 새 테스트 2파일을 돌렸다:
|
||||
|
||||
```
|
||||
60 failed, 12 passed, 1 skipped in 275.50s
|
||||
```
|
||||
|
||||
실패 60건은 S1(교차 서버)·S4~S6(다이얼로그)·S7/W(started 증거)·S8(rc 4)·S9/S10(롤백)·R1~R4(resume) 전 영역에 걸친다. 통과한 12건은 "같은 서버는 스코프 유지"(S2/S2b)·"shim 은 바뀌지 않음"(S11)·"호출자가 준 토큰은 덮어쓰지 않음"(S5e) 같은 **불변 조건**들이라 수정과 무관한 것이 맞다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
|
||||
|
||||
### 6-3. 격리·픽스처·시간
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
|
||||
| 실제 herdr 서버·홈 | `mam_sandbox` 가 `HOME`/`HOME_DIR` 를 `tmp_path` 로 바꾸고(`conftest.py:210-211`) herdr 는 mock. 새 파일에서 `hivemq`·실제 소켓 참조 0건. 이 pane 의 herdr 환경변수는 S1d 가 **읽기만** 한다 |
|
||||
| 실제 생산자 사용 | S7i~S7n·W 계열은 `REGISTRY_PY`/`PUBLISH_PY`(실제 `registry.py`·`publish_event.py`)로 잡을 등록하고 `started` 를 발행한다(`test_issue23_24_startup_path.py:620-660`). "손으로 만든 픽스처가 결함을 가렸다"는 PR 의 교훈이 테스트에 반영돼 있다 |
|
||||
| 검증 경로 | tracked 파일 변화 없음(HEAD `f4ba205` 유지). 내 실행이 무시되는 `.mam/` 을 만들었다(#32 리뷰 때와 같은 현상, 그대로 둠) |
|
||||
| 실행 시간 | 새 파일 2개는 대체로 빠르지만 **`test_issue24_resume_dialogs.py` 4건이 각 39~43 s** 다(resume 의 `handle_startup_dialogs … 20` 타임아웃과 mock 준비 대기가 겹친다). 스위트 전체 시간에 +2.7 분을 더한다(F6) |
|
||||
|
||||
## 8. 병합 차단 여부 (항목 8)
|
||||
|
||||
버전 락스텝 bump 부재(`lib.sh:32` `4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||
|
||||
| # | 비차단 지적 | 근거 / 권고 |
|
||||
|---|---|---|
|
||||
| F1 | **codex 수락 키가 브리프 서술과 다르다.** 코드: 신뢰 창 `Enter`(1번 미리 선택), 업데이트 안내 **`2`**(Skip, 이번만). 브리프: `1`+Enter, `3`+Enter(Skip until next version). S4 의 codex 화면 픽스처(`Update available / 1. Update now / 2. Skip / 3. Skip until next version`)를 보면 3개 항목이 실제로 있고, 코드는 그중 **2** 를 고른다 — "아무것도 바꾸지 않는" 선택이라는 주석과 일치하나 다음 시작 때 다시 뜬다. 실제 시험은 PR 본문대로 통과했으므로 동작은 성립 | 병합 전 브리프 작성자와 한 번 맞출 것; `3` 이 의도라면 어댑터 한 줄 |
|
||||
| F2 | 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없다 | 한 문단 추가 권고 |
|
||||
| F3 | `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 영향을 준다는 점이 SKILL.md 에 없다(`create_session.sh:658`) | 문구 보강 |
|
||||
| F4 | acceptance5 가 "retained `completed` 만 배달" 된 경우를 이제 실패로 본다(계약상 옳음). 공개 브로커에서 flake 가 늘 수 있는 자리 | 관찰되면 skip 조건 재검토 |
|
||||
| F5 | 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않는다 — pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험은 낮다 | 정보 |
|
||||
| F6 | `test_issue24_resume_dialogs.py` 4건이 각 ~40 s | 타임아웃을 env 로 줄일 수 있으면 개선 |
|
||||
|
||||
## 9. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # f4ba205… / 88cb4f3…
|
||||
# 교차 서버 규칙 (함수만 추출, env -i)
|
||||
eval "$(sed -n '/^_herdr_caller_server() {/,/^}/p; /^_herdr_call_is_cross_server() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
|
||||
env -i HERDR_SOCKET_PATH=/x/.config/herdr/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server && echo CROSS"
|
||||
env -i HERDR_SOCKET_PATH=/x/.config/herdr/sessions/mam/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server || echo same"
|
||||
# 증거 규칙: 이전 토큰의 started / fingerprint 없는 감사 기록은 토큰 요구 시 거부 (§3-2 의 10 케이스)
|
||||
# 수정 없이는 실패하는지 (사본에서만): 소스 12파일을 1e78dae 판으로 교체 후 새 테스트 2파일 → 60 failed / 12 passed
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,42 @@
|
||||
# PR #56 4차 재리뷰 — 수정 커밋 21f0c12 (job ae9c1f28)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261012T013821Z-656dfe / base efd27def .. head 21f0c12b (커밋 1개, 2 파일) / tree c2357ac0
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261012T013821Z-656dfe/1` — HEAD·tree 가 identity 와 일치, `git status --porcelain` 0 줄(시작·종료 시). 본 체크아웃 HEAD 42e5173(main) 변동 없음.
|
||||
- 실행: 워크트리 venv python 3.9.6, `env -i` 로 HERDR_*/MAM_*/MQTT_*/WORKSPACE_ROOT/CLAUDE_PROJECT_DIR/HOME_DIR/CODEX_HOME 을 비우고 호스트의 긴 TMPDIR 은 그대로. `.mam.env` 는 읽지 않았다. 범위는 이 커밋만.
|
||||
|
||||
## 1. 전제 확인 — 'default' 스캔은 정말 HERDR_SESSION_NAME 의 서버를 보는가 (확인할 것 1)
|
||||
|
||||
**코드.** reconcile 의 스캔은 `subprocess.run(['herdr', ...])` 로 PATH 의 `herdr` 를 부른다(`reconcile.sh:505-510`). lib.sh 를 source 하면 `$WORKSPACE_ROOT/.mam/shim` 이 PATH 맨 앞에 들어가고(`lib.sh:144-147`), create_session.sh 는 lib.sh 를 source 한 채 `reconcile.sh --once` 를 자식으로 띄우므로(`create_session.sh:748`) 그 reconcile 의 `herdr` 는 shim 이고 `HERDR_SESSION_NAME`(= create 의 서버, 워크스페이스 슬러그)을 물려받는다. shim 은 `-L X` 를 `HERDR_SESSION_NAME=X` 로 바꾸고(`lib.sh:184-190`), 실제 바이너리를 부를 때 `HERDR_SESSION_NAME` 이 비어 있지 않고 `default` 가 아니면 `--session <그 값>` 을 앞에 붙인다(`lib.sh:270-283`; `ls` 경로의 python 도 `MAM_LS_SESSION` 으로 같은 값을 씀, `:1256-1318`). 따라서 `-L` 없는 'default' 스캔 = `herdr --session <HERDR_SESSION_NAME> ls` 이다. 전제 성립.
|
||||
|
||||
**실서버(herdr 0.9.3, 일회용 HOME `/tmp/ae9c-h*`, 일회용 서버 이름).**
|
||||
- lib.sh 를 source 한 셸에서 `HERDR_SESSION_NAME=t-ae9c-srv` 로 shim `herdr ls -F …`(`-L` 없음) → rc 0. 그 HOME 아래 생긴 서버는 `.config/herdr/sessions/t-ae9c-srv/` 하나뿐이고 default 소켓 `.config/herdr/herdr.sock` 은 생기지 않았다 → 'default' 스캔이 환경변수의 서버로 갔다. `-L t-ae9c-srv ls` 도 rc 0(같은 서버). 서버는 `--session t-ae9c-srv server stop` 으로 내리고 HOME 제거, 잔류 프로세스 0.
|
||||
- 참고로 **원 바이너리는 `HERDR_SESSION_NAME` 을 모른다**: `/opt/homebrew/bin/herdr status server` 는 `HERDR_SESSION_NAME=t-ae9c-x` 에서 default 소켓(`…/herdr.sock`)을, `HERDR_SESSION=t-ae9c-y` 와 `--session t-ae9c-z` 에서 `sessions/<이름>/herdr.sock` 을 가리켰다. 즉 전제는 shim 을 거칠 때만 성립하는데, reconcile 은 위와 같이 shim 을 거친다(그리고 lib.sh 를 source 한 셸에서는 `herdr` 자체가 함수다). 수정이 "HERDR_SESSION_NAME 이 비어 있지 않고 default 가 아닐 때" 로 조건을 건 것은 `_real_herdr` 의 조건(`:271-274`)과 정확히 같은 경계다.
|
||||
|
||||
## 2. 변경 자체 (`reconcile.sh:659-666`)
|
||||
- drift B 에서만, `srv == 'default'` 이고 ambient 가 유효할 때 `srv` 를 ambient 로 바꾼다. 이후의 `server_opt`, `herdr_session`/`herdr_server`, `start_command`/`attach_command`/`kill_command` 가 모두 그 `srv` 를 쓰므로 행이 일관되게 "답한 서버" 로 적힌다. `srv` 는 루프 머리(`:607`)에서 매 반복 `t['server']` 로 다시 읽히므로 다음 항목으로 새지 않는다. 스캔(`:505`)·drift A(`:594-595`)·`pane_meta(name, srv)`(`:650`, 스캔한 라벨로 호출 — 같은 ambient 로 답하므로 동일 서버) 는 그대로다.
|
||||
- bash 변경 없음(`bash -n` 통과), python 3.9 호환.
|
||||
- `tests/data/skill_code_blocks.json`: `create_session.sh:550` 이 실제로 `raise SystemExit(4)` 임을 확인(`sed -n 550p`), `test_issue42_skill_blocks` 통과.
|
||||
|
||||
## 3. 부작용 검토 (확인할 것 2)
|
||||
- **다음 reconcile 에서 drift A 로 오판되지 않는가.** 행의 서버가 X(ambient)로 적히면 다음 reconcile 은 그 행에서 X 를 `unique_servers` 에 넣고(`:499-502`) `-L X` 로 스캔하므로(`:505-507`) `alive_set` 에 `(name, X)` 가 들어가고(`:581`), drift A 의 비교 `(name, X)`(`:594-595`)가 맞는다 — 데몬의 환경(HERDR_SESSION_NAME 유무·다른 값)과 무관하다. 같은 create 환경에서는 'default' 스캔도 X 로 답해 `(name,'default')` 까지 더해지지만 비교에 쓰이지 않을 뿐 해가 없다. scenario4 가 바로 이 경우다: 같은 폴더 6 개 create 가 각자 `reconcile --once` 를 띄워 서로의 pane 을 보며, 6 행이 모두 살아남아야 한다 → 통과(아래).
|
||||
- **기존 'default' 행·HERDR_SESSION_NAME 없는 데몬.** 조건이 ambient 가 비어 있거나 'default' 면 발동하지 않으므로(`:665`) 종전과 같은 'default' 라벨이 적힌다. 이미 'default' 로 적힌 행의 drift A 판정은 이 커밋이 건드리지 않았다.
|
||||
- **같은 pane 이 'default' 스캔과 X 스캔에 두 번 보일 때.** `herdr_sessions` 에는 `(name,'default')`·`(name,X)` 두 항목이 들어오지만 drift B 는 이름 기준으로 건너뛴다: `any(_sanitize(y) == name for y in yaml_session_names)`(`:610`) 와 등록 직후의 `yaml_session_names.add(name)`(`:715`). 어느 항목이 먼저 오든(정렬된 서버 순) 첫 항목이 X 라벨로 등록되고 두 번째는 건너뛴다 → 중복 없음. 두 경로 모두 라벨은 X 로 같다(`-L X` 는 `srv=X`, 'default' 는 ambient 로 교정).
|
||||
- **`HERDR_SERVER_NAME` 만 있는 경우.** `unique_servers` 는 그 값을 넣지만(`:497-498`) 라벨 교정은 `HERDR_SESSION_NAME` 만 본다. shim 의 라우팅(`_real_herdr`)도 `HERDR_SESSION_NAME` 만 보므로 그때의 'default' 스캔은 실제로 default 서버를 본다 — 라벨 'default' 가 맞다. 일관됨.
|
||||
- 비차단 메모(기존 코드): `:610` 의 `(name, srv) in yaml_session_names` 는 이름 집합에 튜플을 넣어 보는 비교라 항상 거짓이고, 실제 중복 방지는 뒤의 `any(...)` 가 한다. 동작에는 영향 없음.
|
||||
|
||||
## 4. 테스트 (확인할 것 3)
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| head: `test_tier4_e2e.py::test_e2e_scenario4_parallel_flock_locking` + `test_issue50_pane_lock.py` + `test_issue42_skill_blocks.py` + `test_issue9_server_env.py` + `test_b19_headless_reconcile_fixes.py` + `test_tier1_unit.py` + `test_tier2_component.py` + `test_workspace_scope.py` + `test_b4_session_created.py` | **212 passed, 1 skipped** (464 s) |
|
||||
| head: `test_issue50_dispatch_herdr.py` (실서버 16 케이스, a3 = 같은 폴더 3 개 동시 create 포함) | **16 passed** (548 s) |
|
||||
| 수정 전 대조군: `git archive HEAD` 복사본에 reconcile.sh 만 efd27de 로 되돌리고 scenario4 | **실패** — `ERROR: parallel-sess-3-creator-claude already running in agent-sessions.yaml`, `assert 4 == 0` (브리프의 증상 그대로 재현) |
|
||||
|
||||
## 5. 잔류물
|
||||
- 일회용 HOME `/tmp/ae9c-h`, `/tmp/ae9c-h2`, `/tmp/ae9c-h3` 제거, 일회용 서버 `t-ae9c-srv` 정지(잔류 프로세스 0). `/tmp/mam50-*` 박스 0, 실험 herdr 서버 0. 검증 경로에는 `.mam` 조차 생기지 않았고 `git status` 깨끗. scratch 복사본·basetemp·프로브 스크립트는 세션 scratchpad 아래. 실제 `.mam`·모니터·세션·실제 서버·에이전트 홈은 건드리지 않았다(실제 서버에 대해서는 `herdr status` 읽기 한 번뿐).
|
||||
|
||||
## 결론
|
||||
'default' 스캔이 shim 을 거쳐 HERDR_SESSION_NAME 의 서버를 본다는 전제는 코드와 실서버로 확인됐고, 행을 "답한 서버" 로 적는 교정은 drift A·중복 등록·기존 행·환경변수 없는 데몬에 부작용이 없다. create 의 자기 서버 행 인수(F2)와 맞물려 scenario4 가 이 Mac 에서 통과하며, 수정 전에는 브리프의 rc 4 로 실패한다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "af549b13",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-23T05:16:58Z"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# PR #69 (Issue #63) 리뷰 — head 1614af5
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS, bash 3.2, /usr/bin/python3 3.9.6)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`
|
||||
- identity 확인: HEAD 1614af5b673d7519d3a38f63e3636055592b4f14, tree f1af6fdd548ee5d6f6b81e2202833edfaca98188, `git status --porcelain --ignored` 비어 있음, `.mam` 없음 (시작·종료 시 모두). main HEAD dc886ec 그대로.
|
||||
- 범위: base dc886ec..1614af5 전체(커밋 13개).
|
||||
- ede9c62 까지는 이전 리뷰(잡 8445af7f, 55f86b49)에서, 8f64174·7cac6f6 은 잡 1c6eca2b 에서 봤다. 그 결론을 다시 확인했다.
|
||||
- 이번에는 1614af5 와 세 수정 사이의 정합성을 중점으로 봤다.
|
||||
- 참고: 같은 head 를 맡은 잡 906427d1 은 관련 스위트 도중 검증 경로(`…a646cb/3`)가 사라져 끝내지 못했다. 그 잡에서 얻은 head 결과(issue63 27 passed, 변이 결과)는 같은 tree 의 스크래치 사본에서 나온 것이다. `diff -rq` 로 이번 검증 경로와 같음을 확인했다.
|
||||
|
||||
## 1. 1614af5 가 바꾼 것
|
||||
|
||||
- `.agents/skills/lib_py/agents/id_order.py:223`: `elif sess == me or not sess:` 를 `elif not sess:` 로 바꿨다.
|
||||
- 그래서 호출자 자신의 세션 이름에 남은 죽은 owner 마커도 고아 규칙을 따른다. 이번 시도의 행이 등록되면 지우고(:225), herdr 가 정상 목록으로 "없음"이라 답하면 지우고(:228-229), 그 밖에는 남긴다.
|
||||
- `_read_and_prune_markers` 의 `me` 인자를 지웠다(:183, :308).
|
||||
- `blockers` 는 ede9c62 부터 `m_sess == me` 를 건너뛰지 않는다(:136-144). 그래서 남은 자기 이름 마커가 실제로 막는다.
|
||||
|
||||
## 2. 세 수정이 각 지적을 닫는가, 서로 어긋나지 않는가
|
||||
|
||||
1. **8f64174 — 조회 실패를 "없음"으로 읽던 문제**
|
||||
- `herdr_session_state` (:58-86) 는 정상 파싱한 `agent list` 에 이름이 없을 때만 False 를 돌려준다. 그 밖에는 None 이다.
|
||||
- prune 은 `drop = live is False` (:228) 다.
|
||||
- 변이 m1(`not live`)과 m2(rc≠0 → False)가 테스트에 잡혔다(잡 1c6eca2b).
|
||||
2. **7cac6f6 — 상한 0 에서 30 초 조회, server_not_running**
|
||||
- 조회 시간은 `deadline - now` 다(:288). timeout≤0 이면 None 을 돌려준다(:64-65).
|
||||
- 변이 m4(고정 30 s)와 m7(timeout≤0 에도 조회)이 잡혔다.
|
||||
- 상한 0 테스트는 0.3 s 안에 끝나는 것을 확인한다(test :1222-1224).
|
||||
3. **1614af5 — 같은 이름의 죽은 owner 마커를 확인 없이 지우던 문제**
|
||||
- 옛 규칙(`sess == me` 이면 drop)을 되돌린 변이 사본에서는 두 테스트가 실패한다: `test_id_order_dead_owner_marker_blocks_while_its_unregistered_herdr_session_is_alive`, `test_id_order_real_probe_against_mock_herdr_fails_closed`.
|
||||
- 커밋 메시지가 말한 "재시도의 실패 정리가 살아 있는 pane 을 죽임"도 막힌다. create 의 대기(`create_session.sh:287`)는 롤백 trap 설치(:367)보다 앞서 있어서, 대기에서 거부(exit 1)되면 `cleanup_herdr_on_error` 가 돌지 않는다.
|
||||
4. **정합성**: 세 수정은 모두 `_read_and_prune_markers` 의 "죽은 owner 마커" 분기 하나를 쓰고, 조회는 `bounded_probe` 하나를 거친다. 자기 이름 마커만 다른 규칙을 쓰던 예외가 사라져서 규칙이 하나로 모였다. 어긋나는 곳은 찾지 못했다.
|
||||
|
||||
## 3. 흔한 사용을 600 초 대기로 막지 않는가
|
||||
|
||||
스크래치에서 `id_order.wait` 를 프로세스 안에서 직접 불렀다. 조회 함수를 주입하고, cap 2 s(일부 0 s)로 돌렸다.
|
||||
|
||||
**정상 사용 (실패 없음)**: 막지 않는다.
|
||||
- create 마커가 남은 채로, 그 세션이 다음 상태일 때 같은 이름으로 resume 했다:
|
||||
- stopped 이고 own id 가 기록됨
|
||||
- stopped 이고 id 없음 (herdr-dead stop)
|
||||
- reconcile 이 terminated 로 바꿈
|
||||
- 세 경우 모두 herdr 가 **답하지 못해도**(None), cap 0 에서도 rc=0 이었다.
|
||||
- `blockers` 의 `pinned_for_attempt` 와 `ended_after_attempt` (:158-171) 가 이 경우들을 걸러낸다.
|
||||
- 정상 create/resume 이 끝난 뒤의 마커는 행이 running 인 동안에는 `_registered_for_attempt` (:177-180) 로 지워진다.
|
||||
|
||||
**실패 뒤 재시도**:
|
||||
|
||||
| 경우 | herdr 가 "없음" 응답 (서버 살아 있음) | herdr 무응답 (서버 꺼짐 등) |
|
||||
|---|---|---|
|
||||
| create X 가 행 쓰기 전에 실패(exit 42 롤백) → create X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||
| resume X 가 spawn 에서 실패(행 그대로) → resume X 재시도 | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||
| 위 resume 실패 → 같은 cwd 에서 create Y | rc=0, 0.0 s | rc=1, cap 만큼 대기 |
|
||||
|
||||
- 7cac6f6 사본으로 같은 시뮬레이션을 돌리면 무응답 열의 위 두 줄(같은 이름 재시도)이 rc=0 이다.
|
||||
- 즉 1614af5 는 서버가 살아 있는 흔한 재시도는 그대로 두고, herdr 가 답하지 못하는 재시도만 막는다. 이는 아래 R1 이 같은 이름까지 넓어진 것이다.
|
||||
|
||||
## 4. 그 밖의 확인
|
||||
|
||||
브리프 항목 1·2·4·5·6·8(완료 조건 테스트, autopin 재사용, resume·stop, 지운 심볼, issue34 무관)은 1614af5 가 `session_ids.py`, `lib.sh`, 셸 스크립트, 문서를 건드리지 않으므로 이전 리뷰의 결론 그대로다.
|
||||
|
||||
**macOS**:
|
||||
- `/usr/bin/python3` 3.9.6 으로 `lib_py.agents.id_order` import 가 성공한다(잡 1c6eca2b; 1614af5 는 문법 요소를 더하지 않는다).
|
||||
- 잠금은 `fcntl.flock` 이다(:294-295).
|
||||
|
||||
**실제 herdr 를 건드리지 않았는가**:
|
||||
- 기존 서버 두 개(pid 3407 `herdr server`, 8323 `herdr --session multi-agent-mux server`)는 스위트 전후로 같다(시작 시각 08:21).
|
||||
- 스위트 중 13:22 에 `herdr --session mam-pylecturenotes server` 가 새로 보였다. 하지만 이 프로세스는 실제 HOME 과 `WORKSPACE_ROOT=/Users/godopu16/PuKi/lac/curriculum/01_python_getting_started_lecturenotes` 로 떠 있고, 사용자의 attach 클라이언트(13:27)가 붙어 있다. 테스트는 HOME 을 스크래치로 바꿔 돌므로 다른 작업 공간의 것이다.
|
||||
|
||||
## 5. 실행한 테스트
|
||||
|
||||
모두 `env -i`, 임시 HOME/XDG, `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<scratchpad>` 로 돌렸다. 테스트에서 .mam.env 를 export 하지 않았다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로: `tests/test_issue63_same_cwd_ids.py` + 관련 19개 모듈 (issue40_auto_pin, issue64_ro_connect, issue10_13_purge, issue31_purge_residual, issue42_skill_blocks, tier1_unit, tier2_component, b4_session_created, issue24_resume_dialogs, issue30_stall_watch_stop, issue23_24_startup_path, issue45_restore, issue50_pane_lock, c1_tui_readiness, issue6_fail_closed, issue60_opencode_v2, issue60_linux_fixes, uuid_target, issue17_workspace_root_isolation) | **597 passed, 2 skipped, 0 failed** (32 분) |
|
||||
| 같은 tree 스크래치 사본: issue63 단독 | 27 passed |
|
||||
| base 사본 + head 테스트 | 27 errors (`ImportError: id_order`). 조건별 base 실패는 1차에서 22 failed + 1 passed 로 확인했다 |
|
||||
| 변이: 1614af5 를 되돌림(자기 이름 마커를 drop) | 2 failed (위 두 테스트) |
|
||||
|
||||
## 6. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 7. 비차단
|
||||
|
||||
### R1 (잡 1c6eca2b 의 R1, 범위가 넓어짐): herdr 가 답하지 못하는 고아 마커는 같은 폴더·같은 종류의 create/resume 을 매번 cap 동안 붙잡는다
|
||||
|
||||
- 이제 같은 이름 재시도도 포함된다(위 표).
|
||||
- 서버가 꺼진 상태(server_not_running)가 대표적이다.
|
||||
- 조회는 서버를 띄우지 않는다(:67).
|
||||
- create/resume 은 대기를 통과해야 spawn 으로 서버를 띄운다(`create_session.sh:287`→:289, `resume_session.sh:243`→:260).
|
||||
- 그래서 다른 무언가가 서버를 켜기 전까지는 기본 600 s 를 기다린 뒤 거부가 반복된다.
|
||||
- 회복 경로는 있다: 메시지의 `HERDR_SESSION_NAME=<srv> herdr kill-session -t <s>` 를 shim 으로 실행하면 서버가 기동된다(잡 1c6eca2b 의 가짜 herdr 재현에서 `--session srv-down server` 1회).
|
||||
- 생기는 조건은 "행 등록 전 실패나 kill"에 "서버가 꺼짐"이 겹치는 경우다. 정상 사용과 서버가 살아 있는 재시도는 막지 않는다(3절).
|
||||
- 권고 (둘 중 하나):
|
||||
- (a) server_not_running 만은 "없음"으로 읽는다. MAM 서버는 restore 가 꺼져 있어서 꺼진 서버 위에는 살아 있는 pane 이 없다. `lib.sh:204-207` 의 ISSUE-9 규약과도 맞는다.
|
||||
- (b) 그 경우는 cap 을 기다리지 말고 바로 거부한다. 메시지는 "서버가 꺼져 있음 → 위 명령으로 정리"처럼 사실대로 쓴다.
|
||||
- fail-closed 를 택한 것은 루프에서 정한 설계 선택이라 차단으로 두지 않는다.
|
||||
|
||||
### 그 밖의 비차단 (잡 1c6eca2b 와 같음)
|
||||
|
||||
- **N1**: 조회가 `MAM_NO_AUTOSTART=1` 을 쓴다는 것(:67)을 고정하는 테스트가 없다.
|
||||
- **N2**: sanitize 한 이름 비교(:85)를 고정하는 테스트가 없다.
|
||||
- **N3**: `flock(LOCK_EX)` 대기(:295)는 시간 제한이 없다. 다른 호출자가 앞 대기의 남은 시간만큼 더 기다릴 수 있다. 상한은 있다.
|
||||
- **N4**: create·resume SKILL.md 의 exit 1 설명이 고아 마커로 거부되는 경우와 그 정리 방법을 말하지 않는다.
|
||||
- **N5**: 이전 라운드의 비차단 항목이 남아 있다: resume `mam_id_order_wait` 제거가 테스트로 고정되지 않음, c6(b) 의 1.5 s 타이밍 flake, lib.sh `--herdr-server` 전달이 테스트되지 않음.
|
||||
|
||||
## 8. 결론
|
||||
|
||||
- 1614af5 는 codex 의 3차 지적(같은 이름의 죽은 owner 마커를 확인 없이 지움)을 닫는다. 8f64174·7cac6f6 과 같은 고아 규칙 하나로 모였다.
|
||||
- 정상 create/resume 과 서버가 살아 있는 재시도는 막지 않는다.
|
||||
- herdr 가 답하지 못할 때 cap 동안 기다리는 비용(R1)은 회복 경로가 있는 설계 선택이므로 비차단이다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,35 @@
|
||||
# 리뷰 리포트 — Job b0c15e45 (PR #51 2회차 델타, 518beda..4c86107)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261007T155310Z-56d5a7 / base 518beda / head 4c86107 / tree e6f67f6 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위(항목 4): lib_py/delegation/wrapper.py `_sleep`(+6/−3, 주석 포함)과 테스트 1개 추가. 래퍼 bash·문서·버전 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
codex R1 이 닫혔다. monotonic 은 `_sleep` 의 대기 길이에만 쓰이고, 마감·evidence 창·이벤트 시각은 벽시계 그대로라 옛 bash 정책과 같다. 새 테스트는 결정적이며 이전 코드에서 실패한다. 차단 결함 없음.
|
||||
|
||||
## 1. R1 종결
|
||||
| 지적 (codex 1회차) | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| R1 `_sleep` 이 `time.time()` 으로 남은 시간을 재서, 대기 중 벽시계가 뒤로 바뀌면 대기가 늘어남(1 s→11 s, 3 s→13 s) | wrapper.py:145-156 — `end`/`left` 를 `time.monotonic()` 으로 계산 | 새 테스트 test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back[1, 3] 통과. 이전 wrapper.py(518beda)를 넣은 스크래치 사본에서는 `assert 11.0 <= 1.5`, `assert 13.0 <= 3.5` 로 **2 failed** — 지적한 증상 그대로 재현 |
|
||||
|
||||
## 2. 벽시계 정책이 섞이지 않는가
|
||||
- monotonic 이 쓰이는 곳은 `_sleep`(:150, :153) 뿐이다. 벽시계는 그대로: `_now()`(:45-46, bash `date +%s`), 마감 `start_time + timeout`(:203), broker 재관측의 남은 시간 `deadline - _now()`(:208)와 마감 도달 판정(:218), 유예 루프 조건·evidence 창·마감 판정(:226-240), 디스크 heartbeat 의 나이 `time.time() - seen`(:115, 이벤트 timestamp 가 벽시계이므로 맞다).
|
||||
- 옛 bash 와 같다: `sleep 1`/`sleep "$left"` 는 경과 시간(벽시계 무관)이고, 마감·창은 `$(date +%s)`. broker 루프는 `min(deadline - _now(), 3)` 을 벽시계로 구한 뒤 그 길이만큼 monotonic 으로 잔다 — bash 의 `left` 계산 후 `sleep "$left"` 와 동일.
|
||||
- 래퍼 생존 확인(0.5 s 간격)은 `_sleep` 안의 `time.sleep(min(left, 0.5))` 와 매 반복 `_check_owner` 로, 간격 자체가 monotonic 남은 시간 기준이 되었을 뿐 확인 빈도·조건은 같다.
|
||||
|
||||
## 3. 테스트 결정성
|
||||
- `_SteppedClock` 이 모듈의 `time` 을 대체해 실제 시간·sleep 을 쓰지 않는다(0.1 s 에 끝). 첫 sleep 중 벽시계만 10 s 역행시키고 `slept` 를 정확히 누적하므로 결과는 입력으로 결정된다(현재 코드에서 slept == seconds). 3회 반복 모두 통과. 상한 `seconds + 0.5` 와 옛 결과(+10)의 차이가 커서 오탐 여지가 없다.
|
||||
- 이 테스트는 벽시계가 **앞으로** 뛰는 경우는 다루지 않지만, 그 경우는 옛 bash 와 마찬가지로 마감 쪽(벽시계)이 일찍 끝나는 것이 정책이므로 대상이 아니다.
|
||||
|
||||
## 4. 테스트 실행
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| 지정 2개 파일(test_issue17_item4_decisions, test_issue33_budget), pr-51 .venv Python 3.9.6 | **278 passed / 0 failed** (325 s) |
|
||||
| 새 테스트, 이전 wrapper.py 사본 | 2 failed (11.0, 13.0) |
|
||||
- 브리프 주의대로 모든 실행을 `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 를 비우고 임시 HOME(스크래치)으로 돌렸다(탐침 사본에는 WORKSPACE_ROOT 를 그 사본으로 명시). 실제 .mam·다른 워크스페이스·herdr 서버를 건드리지 않았다.
|
||||
- PR 설명의 전체 스위트 실패 1건(test_issue45_restore 의 $HOME 복원, herdr 0.9.3)은 이 델타가 건드리지 않는 파일이며 main 에서도 같다는 설명과 일치한다(이번에 재실행하지 않음).
|
||||
|
||||
## 5. 비차단
|
||||
1회차 N1~N3·N5 는 후속으로 남겨 둔 데 동의한다. 이번 델타에 새 관찰은 없다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261015T123502Z-1b9df6",
|
||||
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
|
||||
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
|
||||
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
|
||||
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "b48be8a6",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-15T13:18:35Z"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# PR #58 (fix/issue-57) 병합 전 2차 리뷰 (job b48be8a6)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261015T123502Z-1b9df6 / base e32c9574 .. head a7cb90e6 (커밋 15개; 1차 이후 대응 4개: 75c0042 F1, 151af99 F2, 1c598bb R1, a7cb90e R2) / tree 96c5c5f8
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
|
||||
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음; 이 Mac 의 PATH `python3`), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
|
||||
- 실행 안전: 테스트·탐침은 모두 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 또는 `/tmp/jb48-r2`(종료 후 삭제)의 임시 워크스페이스·cwd 에서만 했다. 수정 전 대조군은 `git archive HEAD` 복사본에 제품 파일만 되돌려 만들었다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
|
||||
|
||||
## 1. 1차 지적 4건 — 재현을 다시 돌린 결과
|
||||
|
||||
| 지적 | 1차 재현 (63d8164) | head (a7cb90e) | 수정 전 코드에서 회귀 테스트 |
|
||||
|---|---|---|---|
|
||||
| **F1** paho 없는 폴백에서 control_plane·status 실패 | `AGENT_PYTHON_BIN=/usr/bin/python3` 로 `mam_control_plane_json` rc=1 `ModuleNotFoundError: paho`; `get_job_status` 예외 | rc=0, 바인딩 JSON 에 로그 루트 포함; `status.sh` 의 `get_job_status` 두 복사본 모두 `('abc12345', 'unknown')` | `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json/text]` **실패**(paho 미존재로 control_plane·service import 실패) |
|
||||
| **F2** 래퍼 `logs` 기본 경로가 cwd 상대 | `WORKSPACE_ROOT=ws`, cwd=elsewhere 에서 `logs --list` 가 잡을 못 찾음 | `logs --list` 가 ws 의 잡을 나열, `logs <id>` 에 `registered` 이벤트, `--registry-dir` 를 주면 그 레지스트리를 따름, elsewhere 에 아무것도 안 생김 | `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd` **실패**(`(no logged jobs under …)`) |
|
||||
| **R1** 디스크 폴백이 이후 디스패치의 terminal 을 이전 시도로 오인 | (codex 지적) | 감사 상태 채택 직전에 레코드를 다시 읽어 `(dispatch_token, attempt_id, transition_seq)` 가 처음 읽은 것과 같을 때만 채택 | `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch` **실패**(`B's completion was attributed to A`) |
|
||||
| **R2** jobs 디렉터리가 심볼릭 링크일 때 submit 과 logs 불일치 | (codex 지적) | `/tmp/jb48-r2/alias/jobs -> actual/jobs` 로 `submit --dry-run`: 출력 경로·감사 로그 모두 `actual/delegate_job_logs/<id>`; `logs --registry-dir alias/jobs` 와 `registry.py --registry-dir actual/jobs logs --list` 가 같은 잡을 찾음 | `test_a_symlinked_registry_dir_resolves_to_one_audit_log` 는 63d8164 에서는 `logroot` 부재로 ImportError 라 증거가 안 되므로, **a7cb90e^ (logroot 있음, `abspath` 규칙)** 로 다시 돌려 **실패**(`…/alias/… ≠ …/actual/delegate_job_logs`) 확인 |
|
||||
|
||||
## 2. 수정이 새 문제를 만들지 않았는가
|
||||
|
||||
**F1 (모듈 이동)**
|
||||
- `lib_py/delegation/logroot.py` 는 stdlib(`os`, `typing`)만 쓴다. `mqtt_common` 이 `DEFAULT_REGISTRY_DIR`·`_workspace_mam_dir`·`default_registry_dir`·`get_logs_dir`·`sibling_logs_dir` 를 그대로 재노출하므로 기존 import 경로(`mqtt_common.get_logs_dir` 등)는 불변이다.
|
||||
- `from lib_py.delegation.logroot import …` 는 패키지 `__init__` 을 거쳐 `daemon`·`service`·`registry`·`publish_event`·`publisher` 까지 로드한다. 이 Mac 의 `/usr/bin/python3` 로 직접 import 해 보니 12 개 `lib_py` 모듈이 모두 올라오고 `paho` 는 로드되지 않았다. 직접 paho 를 import 하는 곳은 `mqtt_common`·`publisher` 둘뿐이고 둘 다 `try/except ImportError` 로 감쌌으며 `from __future__ import annotations` 라 `-> mqtt.Client` 주석도 import 시 평가되지 않는다. 실제로 브로커가 필요하면 `make_client` 가 명확한 `ImportError` 를 낸다.
|
||||
- eager import: `logroot` 는 `eager.CORE_MODULES` 에 없지만 목록에 있는 `mqtt_common` 이 import 시점에 로드하므로 동결 런타임에 함께 들어간다(아래 비차단 메모).
|
||||
|
||||
**F2**: `local -a reg=()` + `${reg[@]+"${reg[@]}"}` 를 `/bin/bash -u` 로 직접 확인 — 빈 배열이면 인자 없음, 주어지면 `--registry-dir <dir>`. 래퍼의 다른 subcommand 는 바뀌지 않았다.
|
||||
|
||||
**R1 (재읽기)**
|
||||
- 경합: B 의 terminal 감사 상태는 재디스패치(레코드 토큰·seq 변경)가 커밋된 뒤에만 생긴다. 그러므로 감사 읽기에서 B 의 terminal 이 보였다면 레코드는 이미 그 전에 바뀌었고, 그 뒤의 재읽기가 반드시 차이를 본다. 재읽기 이후의 변경은 이미 읽은 감사 상태와 무관하다.
|
||||
- 같은 시도 안에서 정상 전이(A running → A completed)가 두 읽기 사이에 끼면 seq 가 달라져 "deferred" 로 한 라운드 늦어질 뿐이고, 다음 라운드는 레코드 자체의 terminal 을 읽는다.
|
||||
- 성능: 감사 상태가 terminal 이고 stale 이 아닐 때만 레코드 하나를 더 읽는다. 무시할 만하다.
|
||||
|
||||
**R2 (realpath)**
|
||||
- 기본 경로: `WORKSPACE_ROOT=/tmp/...`(macOS 에서 `/tmp → /private/tmp`)일 때 `get_logs_dir()` 은 이제 `/private/tmp/.../.mam/delegate_job_logs` 문자열을 돌려준다. 물리적으로는 이전과 같은 디렉터리임을 확인했다. 결과를 문자열로 비교하는 곳은 `control_plane.forwarded_keys`(이제 양쪽 모두 `_abs`=realpath)와 루프 러너의 제외 목록(`runner.py` 가 `d.resolve().relative_to(root.resolve())`)뿐이라 영향이 없다.
|
||||
- `forwarded_keys` 탐침: `/tmp`·`/private/tmp` 혼합 두 방향, 후행 `/`, 심볼릭 링크 alias 경유 모두 형제로 판정되어 전파하지 않는다. 비형제만 전파한다. 1차에서 비정규 입력이 "전파" 쪽으로 어긋나던 비대칭도 사라졌다.
|
||||
- `from_env({"WORKSPACE_ROOT": "/tmp/..."})` 의 기본 바인딩은 `defaults()` 와 같아서, 평소 실행은 pane 에 아무것도 전파하지 않는다.
|
||||
- 레지스트리 디렉터리 자체가 심볼릭 링크인 경우에만 감사 로그가 링크 대상 옆으로 간다. 이는 R2 가 의도한 동작이며, 래퍼(`pwd -P`)와 바인딩(realpath)이 이미 그렇게 보고 있었다.
|
||||
|
||||
## 3. 이 Mac 정합성
|
||||
- 변경 py 15 개: `ast.parse(feature_version=(3,9))` 와 `/usr/bin/python3 -m py_compile` 모두 통과. 3.10+ 문법·API 없음.
|
||||
- `lib.sh`·래퍼·`status.sh`: `/bin/bash -n`(3.2) 통과.
|
||||
|
||||
## 4. 테스트 (head, 검증 경로)
|
||||
|
||||
| 묶음 | 결과 |
|
||||
|---|---|
|
||||
| A: issue57·15·21_acceptance·21_worktree_prereqs·tier1·20·17_item4·17_workspace_root_isolation·36·14 | **475 passed, 2 skipped** |
|
||||
| B: issue18·6_fail_closed·34·33·5_phase1/2a/2b/4_runtime·tier2·12·23_24·21_phase2·b7 | **493 passed** |
|
||||
|
||||
`5_phase4_runtime` 의 eager 모듈 검사도 통과했다. 어느 실행도 검증 경로에 `.mam` 을 남기지 않았고 #55 가드 오탐도 나오지 않았다.
|
||||
|
||||
## 5. 비차단 메모
|
||||
- `lib_py.delegation.logroot` 를 `eager.CORE_MODULES` 에 명시하면 의도가 분명해진다. 지금도 `mqtt_common` 을 통해 로드되므로 기능 문제는 없다.
|
||||
- paho 없이 `publisher` 의 브로커 경로를 직접 타면 `make_client` 의 `ImportError` 가 먼저 나는 구조다. 다른 진입점이 `mqtt.` 를 먼저 건드리면 `AttributeError: 'NoneType'` 로 보일 수 있지만, 브로커가 필요한 경로는 원래 paho 가 필수다.
|
||||
|
||||
## 결론
|
||||
1차 지적 4건(F1·F2·R1·R2)이 모두 해소됐다. 1차 재현을 다시 돌려 확인했고, 각 회귀 테스트가 수정 전 코드에서 의도한 이유로 실패한다(R2 는 직전 부모 커밋 기준). 모듈 이동·재읽기·realpath 는 기본 경로, 바인딩·전파, 기존 import 경로에 새 문제를 만들지 않으며, 이 Mac 의 Python 3.9·bash 3.2 에서 정합하다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261019T134017Z-d90c23",
|
||||
"base_sha": "3ce5d5fdc108b0435e933a251fe1c1256b5d7973",
|
||||
"head_sha": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"tree_sha": "f5a412a3ee96465db7c4b3223d2d76e95716ddfa",
|
||||
"review_commit": "b0eccc530149bc6e9211727e19921665c7b80492",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "b78af5ad",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-19T14:59:46Z"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Issue #64 리뷰 — agy·codex·hermes DB 읽기 전용 열기 (job b78af5ad)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T134017Z-d90c23/1`
|
||||
- identity: HEAD `b0eccc530149bc6e9211727e19921665c7b80492`, tree `f5a412a3ee96465db7c4b3223d2d76e95716ddfa` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||
- 범위: base `3ce5d5f`(v4.9.7)..head 1커밋. 판정은 base..head 전체에 대한 것이다.
|
||||
- 참고 자료: creator(agy) 리포트 `.mam/jobs/c721e4f5/agy-reports/report-final.md`
|
||||
- 환경: macOS, Python 3.9.6, sqlite 3.51.0. 실제 에이전트 홈은 `ls`·`lsof`·`ps` 로만 봤고, sqlite 로 연 적은 없다.
|
||||
|
||||
## 결론
|
||||
|
||||
이슈 범위는 빠짐없이 바뀌었다. 조회 전용 열기는 모두 `_ro_connect` 로 바뀌었고, 쓰기와 MAM 자신의 DB 는 그대로다. 새 테스트는 base 에서 실패하고 head 에서 통과한다. 원인도 직접 재현해 확인했다. 차단 결함은 없다. 다만 새 런타임 테스트의 reconcile 단계가 실제로는 drift C 를 실행하지 않는다(N1). 또 기존부터 있던 workspace 간 오고정 문제를 새 테스트가 기대값으로 고정해 버렸다(N2). 둘 다 테스트를 보강하라는 권고다.
|
||||
|
||||
## 1. 원인 확인 — 직접 재현함
|
||||
|
||||
creator 의 결론: `reconcile.sh` drift C(agy, `:786-839`)는 고정되지 않은(unpinned) 실행 중 agy 행이 있으면 호스트 공용 `conversations/*.db` 를 전부 훑는다. 각 DB 에 `verify_session_uuid` 를 부르고, 이어서 `AgyAgentAdapter.verify_artifact` 가 `sqlite3.connect(path)` 로 연다. `last_conversations.json` 이 그 대화를 다른 workspace 의 것으로 적어 두어도 `cache_match=False` 는 `_sibling_claimed_uuids` 만 보고 거르지 않는다(`agy.py:60-72`). 그러면 macOS persistent WAL 때문에 `-wal`·`-shm` 이 남는다.
|
||||
|
||||
재현 방법:
|
||||
- git archive 사본에 임시 테스트를 넣었다(mam_sandbox, 임시 HOME, mock herdr).
|
||||
- 사이드카 없는 WAL agy DB 하나를 만들고, `last_conversations.json` 은 그 대화를 다른 workspace `ws_b` 에 매핑했다.
|
||||
- ws 의 고정되지 않은 running 행을 mock herdr 에 살아 있는 pane 으로 등록했다.
|
||||
- `reconcile.sh --once --emit-diff --dry-run` 을 실행했다.
|
||||
|
||||
| 사본 | DB 디렉터리 결과 |
|
||||
|---|---|
|
||||
| base | `<uuid>.db`, `.db-shm`, `.db-wal` — 사이드카가 생긴다 |
|
||||
| head | `<uuid>.db` 만 — 바뀌지 않는다 |
|
||||
|
||||
이로써 v4.9.7 리뷰 때 #55 가드가 다른 workspace 의 agy DB 옆에서 잡은 사이드카는 MAM 의 reconcile drift C(status·create 가 부르는 경로 포함)가 만든 것으로 설명된다. 다만 그때 그 시각에 고정되지 않은 agy 행이 실제로 있었는지는 기록이 없어 확인하지 못했다.
|
||||
|
||||
## 2. 바뀐 곳 확인
|
||||
|
||||
- **공용 helper.** `_ro_connect` 가 `base.py:111-135` 로 옮겨졌다. 본문은 그대로이고, 임시 복사본 이름만 `os.path.basename(path)` 를 쓴다.
|
||||
- opencode 는 base 의 것을 import 한다(`opencode._ro_connect is base._ro_connect`, 테스트로 고정).
|
||||
- opencode 에서 쓰지 않게 된 `quote`·`tempfile` import 는 지워졌다. `shutil`·`sqlite3` 는 아직 쓰고 있다(`which`, purge).
|
||||
- **`db_rows_naming` 기본 연결.** 기본값이 `_ro_connect` 가 됐다(`base.py:145`). 그래서 codex·opencode 의 residual 확인도 clean WAL DB 를 읽는다. base 에서는 `mode=ro` 가 Mac 에서 `unable to open` 으로 실패해 빈 결과를 냈다.
|
||||
- **조회 전용 열기를 바꾼 곳:**
|
||||
- agy `verify_artifact`(`agy.py:74`)
|
||||
- codex `_find_rollout_via_db`(`codex.py:80`), `discover`(`:195`)
|
||||
- hermes `verify_artifact`(`hermes.py:51`), `discover`(`:100`)
|
||||
- status.sh hermes·codex 두 모드(`:90-91`, `:131-132`, `:290-291`, `:331-332`)
|
||||
- reconcile.sh hermes drift(`:865`), codex drift(`:1048`)
|
||||
- **남은 `sqlite3.connect`(grep 으로 전수 확인).** 모두 대상이 아니라는 판단이 맞다.
|
||||
- 의도된 쓰기: codex·hermes·opencode purge 의 DELETE
|
||||
- MAM 자신의 DB: `lib.sh:1599,2068`, `atomic_yaml.py:61`, `state.py:17`, `verify_session.py:41`(agent-sessions.db), `loop/runner.py:416`
|
||||
- **import 정리.** agy 에서 `sqlite3` import 를 지웠고 다른 사용처는 없다. status.sh 의 opencode 블록은 이제 `lib_py.agents.base` 에서 import 한다.
|
||||
|
||||
## 3. 테스트 실행
|
||||
|
||||
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
|
||||
**새 모듈 `tests/test_issue64_ro_connect.py`**
|
||||
- head: 43 passed.
|
||||
- base 사본에 이 파일만 넣으면: **23 failed, 20 passed**. creator 의 수치와 같다.
|
||||
- base 에서 실패한 것은 모든 clean·walonly 경우, 정적 검사, workspace 간 테스트다. 실패 메시지는 사이드카 `-shm`·`-wal` 이 생겼다는 것이다.
|
||||
|
||||
**관련 13개 모듈**
|
||||
- 대상: issue64, issue60_linux_fixes, issue60_opencode_v2, a4, issue10_13_purge, issue31, orc_onboard, tier1, tier2, uuid_target, issue55, issue57, issue21_worktree_prereqs.
|
||||
- 결과: **461 passed, 1 skipped, 실패 0**, 15분. skip 은 PATH 에 opencode 가 없는 실바이너리 계약 테스트다. #55 가드 오탐은 없었다.
|
||||
|
||||
**뮤테이션** (git archive 사본, issue64 모듈)
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| agy `verify_artifact` 를 `sqlite3.connect` 로 | 5 failed(workspace 간 테스트 포함) |
|
||||
| `db_rows_naming` 기본값을 옛 `mode=ro` URI 로 | 4 failed |
|
||||
| hermes `discover` 를 `sqlite3.connect` 로 | 2 failed |
|
||||
| reconcile hermes drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
|
||||
| reconcile codex drift 를 `sqlite3.connect` 로 | 1 failed — 정적 검사만 잡음 |
|
||||
|
||||
**린트**
|
||||
- `git diff --check` 는 깨끗하다.
|
||||
- 바뀐 Python 6개 파일이 3.9 로 파싱된다.
|
||||
- status.sh·reconcile.sh 는 `/bin/bash -n`(3.2)을 통과한다.
|
||||
|
||||
## 비차단
|
||||
|
||||
- **N1. 런타임 테스트의 reconcile 단계는 drift C 를 실행하지 않는다.**
|
||||
- 위치: `test_status_and_reconcile_runtime_leave_sidecars_alone`(`:241-...`).
|
||||
- 원인: 세 행을 mock herdr 에 등록하지 않는다. 그래서 drift A 가 먼저 `herdr gone … Marked terminated` 로 처리하고 drift C 를 건너뛴다. base 에서 같은 상황을 실행해 drift 결과로 확인했다.
|
||||
- 결과: reconcile 의 hermes·codex 변경은 정적 문자열 검사로만 고정된다(위 뮤테이션 2개).
|
||||
- 권고: 이슈 64 요구는 "실제 실행 또는 최소한 정적 검사"라 충족은 된다. 그래도 mock herdr state 에 세 세션을 `running` 으로 넣으면 실제 drift C 경로가 검증된다(내 재현처럼 `state["agents"][name] = {...}`). status 단계는 실제로 열린다. base 에서 hermes `state.db` 사이드카로 실패한다.
|
||||
- **N2. 기존 결함: drift C(agy)가 다른 workspace 의 대화를 고정한다.**
|
||||
- 내 재현에서 `last_conversations.json` 이 `ws_b` 의 것으로 적은 대화가 epoch 뒤 유일한 후보였다. 그러자 base·head 모두 ws 행에 `updated conversation id: <uuid>` 로 고정했다(dry-run 기준).
|
||||
- 이 PR 이 만든 문제는 아니고 #64 범위 밖이다. 다만 새 테스트 `test_unpinned_agy_reconcile_…`(`:288`)가 `verify_session_uuid(ws_a, …, uuid_b) is True` 를 단언해 이 동작을 기대값으로 고정한다.
|
||||
- 권고: 이 단언은 빼고 디렉터리 목록만 본다. 오고정은 별도 이슈로 다룬다. 예를 들어 `cache_match` 가 아니면서 lc 가 그 uuid 를 다른 cwd 에 매핑하면 거절한다.
|
||||
- **N3.** `_ro_connect` 의 immutable 경쟁 창(ms 수준)은 #60 에서 남긴 것이 공용 helper 를 통해 agy·codex·hermes 로도 넓어졌다. 세 에이전트의 실제 DB 는 모두 WAL 형태다(`ls`: hermes·codex 는 사이드카 쌍이 있고, agy 대화 72개 중 7개에 사이드카가 있음). 실행 중에는 사이드카가 있으므로 `mode=ro` 갈래를 탄다.
|
||||
|
||||
## 관찰·공개
|
||||
|
||||
- **실제 codex DB 의 사이드카.** 관련 모듈 실행 전후로 `~/.codex/` 목록을 비교했더니 `state_5.sqlite-shm`·`-wal` 이 다시 나타나 있었다(mtime 23:29/23:35, 지금은 보유 프로세스 없음).
|
||||
- 이 두 파일은 잡 시작 직후(23:04)에도 있었다가 사라졌던 것이다.
|
||||
- 같은 시간대에 실제 `~/.codex` 를 쓰는 codex 프로세스들이 떠 있다: `codex resume`(이 루프의 리뷰어), app-server daemon, IDE 확장.
|
||||
- 내 실행은 `env -i`·임시 HOME 이었고, head 코드는 사이드카를 만들지 않으며, #55 가드도 아무것도 잡지 않았다. 그래서 테스트 산출물이 아니라 실제 codex 활동으로 본다. 판정 근거로는 쓰지 않았다.
|
||||
- **검증 트리에 임시 파일이 들어갔다 지워짐.** 재현 중 내 셸 명령 실수로 임시 테스트 파일(`tests/test_zz_review_agy.py`)이 검증 트리에 몇 초간 복사됐다. 그 파일로 아무것도 실행하지 않았고 즉시 지웠다. 이후 `git status --porcelain --ignored` 는 비어 있고 HEAD·tree 는 그대로다. 모든 재현은 scratchpad 사본에서 했다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261001T140030Z-34381f",
|
||||
"base_sha": "e1eb22bd59ce825e3a22e86433c30f7ffd833810",
|
||||
"head_sha": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
|
||||
"tree_sha": "584eac551f880232896b898cc43156f28ae1bacb",
|
||||
"review_commit": "0eec795cd1b46f7a28b9a1cc87ef8577619a731f",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "b9600325",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-01T14:27:22Z"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# 🔍 Review Report — PR #43 2회차 델타 (job `b9600325`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-10-01
|
||||
- **대상 델타**: `b58edac..0eec795` (1커밋, 7파일 `+128/−19`, `git diff --check` 클린), base `main e1eb22b` (v4.8.0).
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20261001T140030Z-34381f/1`. **identity 확인**: `HEAD = 0eec795c…731f`, `HEAD^{tree} = 584eac55…bacb` — 브리프와 일치.
|
||||
- **1회차 내 판정**: PASS + R1–R5 (job `645adb2a`). 브리프 지시대로 0eec795 만 본다.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃 모두 수정·커밋·checkout 없음. 실험은 scratchpad 의 합성 env 파일·임시 등록부·래퍼 `--dry-run`(도달 불가 브로커)에서만. 실제 `.mam.env` 를 읽거나 출력하지 않았다. 테스트는 `.mam.env` 미export·`MQTT_*` 미설정. 실제 `.mam`·데몬·세션·herdr 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**네 수정(codex R1–R4)과 내 R1·R2 지적은 모두 닫혔고, 1회차 PASS 를 뒤집을 요소는 없다.** 각 수정을 독립 실험으로 확인했다. 읽기 실패 시 Python 진입점이 멈추는 설계에는 **동의**한다(§5). 비차단 관찰 2건(S1·S2).
|
||||
|
||||
## 1. codex R1 — 읽을 수 없는 파일은 오류 (`envfile.py:56-61`, `:104-109`)
|
||||
|
||||
| 입력 | `--shell` 결과 |
|
||||
|---|---|
|
||||
| 권한 0000 | rc **1**, stdout 없음, stderr `ERROR: …: cannot read (PermissionError)` |
|
||||
| 깨진 UTF-8 | rc **1**, `cannot read (UnicodeDecodeError)` |
|
||||
| 파일 없음 | rc 0, 출력 없음(종전대로 — "설정 없음" 은 정상) |
|
||||
|
||||
래퍼는 rc≠0 에 `ERROR: cannot load $TARGET_ENV (see the line above)` + exit 1(`multi-agent-mux-delegate-job:44`) — 1회차 내 R2(메시지가 원인과 어긋남)도 닫혔다: 이제 위 줄이 실제 원인(PermissionError/UnicodeDecodeError)이다. 값은 어디에도 찍히지 않는다.
|
||||
|
||||
## 2. codex R4 — NUL 이 든 줄 (`envfile.py:66-68`)
|
||||
|
||||
`A=1 / B=x\0y / C=3` 파일: `--shell` 이 `export A=1`·`export C=3` 만 내고 `WARNING: …:2: skipped (NUL byte)`. `parse` 가 두 로더의 공통 경로이므로 Python 로더도 같은 줄을 같은 경고로 건너뛴다 — 두 로더 일치.
|
||||
|
||||
## 3. codex R2 — 로케일과 무관한 ASCII 정수 (`lib.sh:2349-2351`)
|
||||
|
||||
C 와 en_US.UTF-8 두 로케일에서 bash `mam_int` 와 Python `_positive_int` 비교:
|
||||
|
||||
| 입력 | C | en_US.UTF-8 |
|
||||
|---|---|---|
|
||||
| `30` | 30 / 30 | 30 / 30 |
|
||||
| `U+2003 30 U+2003`(em space) | 거부 / 거부 | **거부 / 거부** (수정 전 bash 는 30) |
|
||||
| 아랍 숫자 `٣٠` | 거부 / 거부 | 거부 / 거부 |
|
||||
| `' 30\t'` | 30 / 30 | 30 / 30 |
|
||||
| 전각 `30` | 거부 / 거부 | 거부 / 거부 |
|
||||
|
||||
10/10 일치. 공백 집합 `$' \t\n\r\v\f'` 는 Python `strip(" \t\n\r\v\f")` 와 같다.
|
||||
|
||||
## 4. codex R3 — 명시한 빈 플래그 (`multi-agent-mux-delegate-job:167`, `:201`)
|
||||
|
||||
래퍼 `--dry-run` 실측: `--timeout ''` → rc 1 `ERROR: --timeout : not a positive decimal integer`, `--ack-timeout ''` → rc 1, `--timeout 0` → rc 1. 플래그를 주지 않으면 기본값(종전대로). 값 없이 `--timeout` 만 주면 `set -u` 의 `unbound variable` 로 rc 1 — 이 PR 이전부터의 동작이고 실패 방향이라 무해하다(S2).
|
||||
|
||||
## 5. 설계 질문: 읽기 실패에 Python 진입점이 멈추는 것이 맞는가
|
||||
|
||||
**멈추는 쪽에 동의한다.** 근거:
|
||||
- `.mam.env` 가 실어 나르는 것은 브로커 주소·계정과 `MAM_DISPATCH_SECRET` 이다. 읽기에 실패한 채 "경고 후 계속" 하면 **기본 브로커(공개 `broker.hivemq.com`)로, 서명 없이** 잡 내용을 발행하게 된다 — 예제 파일의 SECURITY NOTE 가 경고하는 바로 그 상황이다. 설정 파일이 있는데 못 읽은 것은 "설정 없음" 과 다르다.
|
||||
- 에이전트 세션 안의 영향(실측): 권한 0000 파일로 `publish_event --event started` 를 돌리면 rc 1 로 끝나고 레코드는 `pending`, `events.log` 는 생기지 않는다. 즉 래퍼의 `started` 대기가 **fail closed**(`agent_blocked`)로 잡을 닫는다 — 조용히 엉뚱한 브로커로 가는 것보다 보이는 실패가 낫다. heartbeat 타이머·구독자도 같은 진입점이라 같은 식으로 멈춘다.
|
||||
- **S1 (비차단)**: Python 진입점에서는 `EnvFileError` 가 **트레이스백**으로 나온다(실측: Traceback 1개, 마지막 줄 `lib_py.delegation.envfile.EnvFileError: …: cannot read (PermissionError)`). 원인은 명확하지만, 에이전트 pane 에 그대로 찍히는 출력이다. `ensure_env_loaded` 를 부르는 진입점(또는 `ensure_env_loaded` 자체)이 `EnvFileError` 를 잡아 `ERROR: <msg>` 한 줄 + `sys.exit(1)` 로 끝내면 래퍼의 `--shell` 과 같은 모양이 된다.
|
||||
|
||||
## 6. 문서 — 내 1회차 R1 (`.mam.env.example:14-26`)
|
||||
|
||||
머리말에 FORMAT 절이 생겼다: 한 파서·`source` 안 함, 공백·따옴표 한 쌍 제거, **확장 없음**(`$HOME`·`${X}`·`$(cmd)`·백틱 글자 그대로 → 절대경로로 쓸 것), **줄 끝 `# ...` 은 값의 일부**, `export`·잘못된 키·`=` 없는 줄·NUL·bash 예약 변수는 줄 번호 경고 후 건너뜀, **환경변수가 파일보다 우선**, 읽기 실패는 오류. 1회차에 지적한 래퍼 사용자 동작 변경 4가지가 모두 적혔다 — 닫힘.
|
||||
|
||||
## 7. 1회차 내 지적의 처리
|
||||
|
||||
| # | 1회차 지적 | 0eec795 |
|
||||
|---|---|---|
|
||||
| R1 | `.mam.env.example` 에 규칙·env-wins 미기재 | **닫힘** — FORMAT 절 |
|
||||
| R2 | `cannot read` 메시지가 원인과 어긋남 | **닫힘** — 실제 읽기 실패가 rc 1 + 원인 줄, 래퍼는 "see the line above" |
|
||||
| R3 | `[ -ge ]` 전용 노브의 비숫자 처리 | 열림(범위 밖, PR 이 분류표로 의도 처리) |
|
||||
| R4 | purge 12건 기준 실패 미재현 | 열림(델타와 무관; §8 재확인) |
|
||||
| R5 | `mam_int` 를 항목 4 이전 목록에 | 열림(후속) |
|
||||
|
||||
## 8. 테스트 (검증 방법)
|
||||
|
||||
```
|
||||
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py tests/test_issue18_liveness_contract.py tests/test_tier1_unit.py
|
||||
(.mam.env 미export, MQTT_* 미설정)
|
||||
→ 174 passed in 210.56s failed 0 · skipped 0
|
||||
```
|
||||
|
||||
- 로그에 공개 브로커·실제 브로커 IP 흔적 0건 — 외부 브로커 없이 통과.
|
||||
- 새 회귀(권한 0000·깨진 UTF-8 에서 래퍼와 Python 로더가 멈춤, NUL 줄을 두 로더가 같게 건너뜀, 두 로케일의 유니코드 공백·숫자 거부, 다섯 정수 플래그의 빈 값 거부)는 위 §1–§4 의 내 독립 실험과 같은 결과를 고정한다.
|
||||
- 기준 실패 주장 재확인: `tests/test_issue10_13_purge.py` 를 이 head 에서 단독 실행 → **23 passed**(1회차의 PR·base 결과와 같음). 이 리뷰어 조건에서는 여전히 재현되지 않는다.
|
||||
- 격리: 실제 `.mam/jobs` 는 이 잡 자신의 파일 외 추가 0, 검증 경로 tracked 파일 변화 없음(HEAD `0eec795`), 본 체크아웃 `main e1eb22b`.
|
||||
|
||||
## 9. 판정
|
||||
|
||||
0eec795 는 지적된 네 결함을 정확히 닫고(각각 실험으로 재현→해소 확인), 새 bash 판정을 만들지 않았다(정수 규칙의 문자 집합을 명시하고 플래그 존재를 기록하는 것은 기존 입력 정제의 보강). 버전 락스텝 bump 는 판정에서 제외한다.
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| S1 | Python 진입점에서 `EnvFileError` 가 트레이스백으로 나온다 — 한 줄 `ERROR:` + rc 1 로 잡아 주면 래퍼와 같은 모양 | `mqtt_common.py:98-104` → 각 `main` 의 `ensure_env_loaded()`; §5 실측 |
|
||||
| S2 | 값 없는 `--timeout`(마지막 인자) 은 `unbound variable` rc 1 — 종전 동작이고 실패 방향 | 래퍼 `parse_opts` 의 `shift 2` |
|
||||
|
||||
## 10. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T140030Z-34381f/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 0eec795… / 584eac5…
|
||||
"$PY" -I "$RP/.agents/skills/lib_py/delegation/envfile.py" --shell <권한0000 파일> # rc 1, cannot read (PermissionError)
|
||||
printf 'A=1\nB=x\0y\nC=3\n' > n.env; "$PY" -I …/envfile.py --shell n.env # export A, C 만 + NUL 경고
|
||||
LC_ALL=en_US.UTF-8: mam_int $' '30$' ' 1 → 거부 (_positive_int 도 거부)
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,67 @@
|
||||
# PR #58 (fix/issue-57) 병합 전 리뷰 — 이 Mac 정합성 중심 (job b9c7522d)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
|
||||
- 리뷰 identity: loop-20261014T152849Z-f1a415 / base e32c9574 .. head 63d81646 (커밋 11개, 21 파일) / tree e8f162f7
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
|
||||
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음 — 이 Mac 의 PATH `python3` 도 이것), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
|
||||
- 실행 안전: 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고 basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 의 임시 워크스페이스·cwd 에서만 했고, base 비교는 `git archive e32c957` 복사본으로 했다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
|
||||
|
||||
## 1. 결과 요약
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Python 3.9 문법(`ast.parse(feature_version=(3,9))`, 변경 py 13 개)·`py_compile` | 통과. 3.10+ 문법·API(match, `X \| None`, 내장 제네릭 주석, `removeprefix`, `tomllib`, `zip(strict=)`) 사용 없음 |
|
||||
| bash 3.2 `bash -n`(lib.sh, 래퍼, worktree_prepare, reconcile, status) | 통과. 래퍼의 `LOGS_DIR_ABS=""` 전역 초기화, `var=$(…) \|\| {…}` 실패 처리, `env_add+=(…)` 모두 3.2 `set -u` 안전 |
|
||||
| head 관련 모듈 A: issue57·15·20·21_acceptance·21_worktree_prereqs·tier1·17_item4·17_workspace_root_isolation·b7·36 | **477 passed, 2 skipped** |
|
||||
| head 관련 모듈 B: issue14·18·6_fail_closed·34·33·5_phase1/2a/2b·tier2·12·23_24·21_phase2·**10_13_purge** | **503 passed**(알려진 실패 #28 의 `test_issue10_13_purge` 도 이 Mac 에서 통과) |
|
||||
| 수정 전 대조군(제품 파일만 base 로 되돌린 복사본) + 새 `test_issue57_logs_follow_registry.py` | **15 failed, 6 errors, 1 passed** — 회귀를 실제로 잡는다 |
|
||||
| 이전 라운드(ee7cef33) 차단 지적: `test_issue15…:64`, `test_tier1_unit…:512` | 두 곳 모두 `monkeypatch.delenv("MAM_JOBS_DIR")` 추가로 해소, 통과 |
|
||||
|
||||
## 2. 맞게 된 부분
|
||||
- **규칙은 한 곳**: `mqtt_common.sibling_logs_dir`(순수) + `get_logs_dir(registry_dir)`(`DELEGATE_JOB_LOGS_DIR` > 레지스트리 형제). 쓰기·읽기 지점(register·pick·legacy/engine 미러·conflict·publish·subscriber·remote·reconcile·runner·lib.sh·래퍼)이 모두 이 함수를 거친다. lib.sh 의 bash 기본값 사본은 `delegate_logs_dir` 로 대체됐고, 해석 실패 시 감사 확인만 건너뛰고 `events.log` 증거는 유지한다.
|
||||
- **macOS 경로**: `control_plane._abs` 는 `realpath(expanduser())` 라 `from_env`·행 기록이 모두 `/private/tmp`·`/private/var` 로 정규화된다. `forwarded_keys` 를 직접 탐침: 형제(정규화 후 동일) → 전파 안 함, `/tmp` 와 `/private/tmp` 가 섞인 비정규 입력·후행 `/`·비형제 → 전파. 어긋날 때는 늘 "전파" 쪽(기존 동작)이라 안전하다.
|
||||
- **디스크 폴백의 재디스패치 안전성**: `job_subscriber._check_disk_fallback` 은 이제 `status.json` 의 `status` 필드를 읽고(이전엔 dict 를 비교해 아무것도 맞지 않았음), 버전 있는 레코드는 감사 `transition_seq` 가 같거나 새로울 때만 채택한다. 이전 시도의 terminal 감사 상태가 새 시도를 끝내지 못하며(`test_old_audit_terminal_does_not_complete_the_redispatched_attempt`), 손상된 seq 는 stale 로 처리한다. 버전 없는 레코드·레코드 없음은 기존 규칙.
|
||||
- **래퍼**: 16 곳의 `${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}` 제거, 해석 실패 시 fail-closed(exit 1), 해석값을 export 해 자식과 주입 명령이 같은 루트를 쓴다.
|
||||
- **worktree_prepare / control_plane**: 호출자가 명시한 `DELEGATE_JOB_LOGS_DIR` 만 넘기고, 유도 가능한 형제 루트는 행에만 기록한다. 기존 행 중 레지스트리를 옮겼지만 로그가 워크스페이스에 있던 것은 비형제라 계속 전파되어 resume 짝이 유지된다.
|
||||
|
||||
## 3. 차단 지적
|
||||
|
||||
**F1. 경로 헬퍼 하나 때문에 create/resume 바인딩과 status 가 paho 를 요구하게 됐다 — 이 Mac 의 문서화된 폴백에서 회귀.**
|
||||
- `lib_py/control_plane.py:28` 이 `from lib_py.delegation import mqtt_common` 을 새로 하고(base 의 control_plane 은 stdlib 만), `status.sh:171,383` 이 `get_job_status` 안에서 `from lib_py.delegation.mqtt_common import sibling_logs_dir` 를 무조건 한다. `mqtt_common.py:33` 은 모듈 최상단에서 `import paho.mqtt.client` 다.
|
||||
- 두 경로의 인터프리터는 `_delegate_py_bin`(`lib.sh:2111-2130`): `AGENT_PYTHON_BIN` > 활성 venv > 스킬 위쪽 가장 가까운 `.venv` > **PATH `python3`**. README 도 이 폴백을 문서화한다. 이 Mac 의 PATH `python3` 은 `/usr/bin/python3` 3.9.6 이고 paho 가 없다(install.sh 는 시스템 python3 에 PyYAML 만 요구).
|
||||
- 재현(임시 워크스페이스, herdr 미호출):
|
||||
```
|
||||
AGENT_PYTHON_BIN=/usr/bin/python3 로 lib.sh source 후 mam_control_plane_json <wt>
|
||||
base: rc=0
|
||||
head: rc=1 ModuleNotFoundError: No module named 'paho'
|
||||
status.sh 의 get_job_status 를 그대로 떼어 /usr/bin/python3 로 job 이 있는 행에 실행
|
||||
base: ('abc12345', 'unknown')
|
||||
head: ModuleNotFoundError: No module named 'paho'
|
||||
```
|
||||
- 영향: 그 폴백에서 `create_session.sh:232-238`·`resume_session.sh:100-106` 은 워크트리 대상이면 바인딩 계산 실패로 **exit 2**(거부), 제어 루트면 `control_plane` 없이 기록한다. 읽기 전용 `status.sh` 는 위임 잡이 걸린 행이 하나라도 있으면 heredoc 이 예외로 죽는다. 둘 다 base 에서는 paho 없이 동작했다.
|
||||
- 수정(작음): `sibling_logs_dir` 를 paho 를 import 하지 않는 모듈(예: `lib_py/paths.py` 또는 새 `lib_py/delegation/logroot.py`)로 옮기고 `mqtt_common` 은 그것을 재노출, `control_plane`·`status.sh` 는 그 모듈에서 import. (또는 `mqtt_common` 의 paho import 를 지연.) 회귀 테스트로 "paho 없는 인터프리터에서 `control_plane json`·`get_job_status` 가 동작" 을 두면 좋다.
|
||||
|
||||
**F2. 래퍼 `logs` 의 기본 경로가 바뀌었다 (레지스트리를 옮기지 않은 평소 실행).**
|
||||
- `multi-agent-mux-delegate-job` `cmd_logs` 는 이제 항상 `--registry-dir "$reg"` 를 넘기고 `reg` 의 기본은 `REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}"`(`:78`) — cwd 상대다. base 는 `--registry-dir` 없이 `registry.py logs` 를 불러 `get_logs_dir()` 이 `$WORKSPACE_ROOT/.mam/delegate_job_logs` 를 썼다(registry.py 자체의 기본 `default=default_registry_dir()`(`:370`) 도 `WORKSPACE_ROOT` 를 존중한다).
|
||||
- 재현(임시 디렉터리, `WORKSPACE_ROOT=ws` 에 잡 하나 등록, cwd=`elsewhere`):
|
||||
```
|
||||
base: logs --list → 그 잡을 나열
|
||||
head: logs --list → "(no logged jobs under <elsewhere>/.mam/delegate_job_logs)"
|
||||
```
|
||||
- 수정(작음): `--registry-dir` 가 주어졌을 때만 넘긴다(이전 라운드 구현처럼 `local reg=()` 배열 + `${reg[@]+"${reg[@]}"}`). 그러면 registry.py 의 기본(`MAM_JOBS_DIR` > `WORKSPACE_ROOT` > cwd)이 그대로 쓰여 기본 경로가 불변이다. "cwd≠워크스페이스에서 `logs`" 테스트 한 건을 권한다.
|
||||
|
||||
두 지적 모두 한두 줄 수정이며 설계 변경은 필요 없다.
|
||||
|
||||
## 4. 비차단 메모
|
||||
- `docs/design/worktree-creator-model.md` 의 status 행(“`MAM_JOBS_DIR`·행 `mam_jobs_dir`·감사 로그(`DELEGATE_JOB_LOGS_DIR`·행 `delegate_job_logs_dir`)·기존 후보 순 조회”)에 새로 추가된 "레지스트리 옆 감사 로그" 후보가 빠져 있다.
|
||||
- `control_plane.forwarded_keys` 는 로그 쪽 값을 정규화 없이 비교한다. 실제 흐름에서는 양쪽이 이미 realpath 라 문제없고 어긋나도 전파 쪽이라 안전하지만, 양쪽에 `_abs` 를 걸면 의도가 분명해진다.
|
||||
- `cmd_logs` 의 `--registry-dir` 뒤 인자가 없으면 `set -u` 로 "unbound variable" 종료 — fail-closed 이므로 무방.
|
||||
- 수정 전 대조군의 6 errors 는 base 코드가 테스트가 기대하지 않는 위치에 쓰면서 생긴 setup/teardown 오류로, head 에서는 없다.
|
||||
|
||||
## 5. 잔류물
|
||||
- 복사본·basetemp·탐침 디렉터리는 모두 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
|
||||
|
||||
## 결론
|
||||
규칙의 일원화, 쓰기·읽기 지점, macOS 경로 정규화, 디스크 폴백의 재디스패치 안전성, bash 3.2·Python 3.9 이식성, 테스트의 회귀 탐지는 모두 맞다. 그러나 (F1) 경로 헬퍼를 paho 를 끌어오는 모듈에서 import 해 이 Mac 의 문서화된 `python3` 폴백에서 create/resume 바인딩과 status 가 깨지고, (F2) 래퍼 `logs` 의 기본 경로가 cwd 상대로 바뀌었다. 둘 다 base 대비 재현됐고 작은 수정으로 끝난다.
|
||||
|
||||
[VERDICT: NOT 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,45 @@
|
||||
# 리뷰 리포트 — Job cd288bf2 (Issue #17 후속 2회차: 상속 WORKSPACE_ROOT, 6b00b6e..8b47cc7)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261008T133858Z-9e7f5b / base 6b00b6e / head 8b47cc7 / tree bfe5fcb — 검증 경로(…/2)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 2개(d198dd5 = 1회차, 8b47cc7 = grok·codex 지적 반영). conftest.py 의 `own_workspace_root` fixture + 테스트 파일 14개 + 새 tests/test_issue17_workspace_root_isolation.py(W1·W2). 제품 코드(.agents/skills)·버전·VERSIONS 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
설계(opt-in fixture `own_workspace_root`, W1 가 모듈별 대표 노드를 체크아웃 사본에서 돌려 `.mam` 미생성을 고정, W2 가 test_orc_onboard 의 import 시점 root 전파를 고정)는 적절하고, 수정된 14개 파일·회귀 테스트·(a)(b) 실행은 모두 요구대로다. 그러나 **조사(2)가 모듈 하나를 놓쳤다**: tests/test_o2_race_free_lock.py 의 `test_o2_12_run_loop_exits_on_lock_failure` 는 HEAD 에서도 자신이 도는 체크아웃의 `.mam/shim/herdr` 를 쓴다(§3). 이 커밋의 제목("every test that sources lib.sh does it under its own WORKSPACE_ROOT")과 W1 의 전제("one test per module that sourced lib.sh … with the inherited root")에 어긋나는 같은 결함이며, 브리프가 리뷰어에게 직접 grep 으로 확인하라고 한 항목이다. 수정은 두 줄(§3)이고 재설계는 아니므로 ESCALATE 는 아니다.
|
||||
|
||||
## 1. 할 일 1·3·4 — 요구대로
|
||||
- `_env`(test_issue17_item4_decisions.py:224-233): 1회차 그대로(제외 + tmp_path 지정). lib.sh:26 이 미설정 시 체크아웃을 유도하므로 "지정"이 맞다.
|
||||
- 회귀 테스트: 1회차의 test_c_helpers_write_nothing_to_an_inherited_workspace 에 더해 W1·W2(새 파일). **수정 전 코드에서 실패 확인**:
|
||||
- W1 상당: base(6b00b6e) 트리 사본 안에서 W1 의 NODES 13개를 `WORKSPACE_ROOT=<사본>` 으로 실행(24 passed) → 사본에 `.mam/shim/herdr` 생성됨(W1 의 `written == []` 단언이 실패하는 조건).
|
||||
- W2 상당: base 의 test_orc_onboard 를 `WORKSPACE_ROOT=<센티널>` 로 import 한 뒤 `run_find_uuid(ws, …)` 호출 → **센티널**에 `.mam/shim/herdr` 생성, ws 에는 없음(import 시점 root 전파 재현). HEAD 의 `_scrubbed_env(root)`(test_orc_onboard.py:24-29)는 호출 시점 root 를 쓴다.
|
||||
- (a)(b) 실행(검증 경로, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_* 제거):
|
||||
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| (a) WORKSPACE_ROOT 없이: 수정 14개 파일 + test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **643 passed / 0 failed** (815 s) |
|
||||
| (b) WORKSPACE_ROOT=<워크트리 밖 센티널>: 수정 14개 파일 | **490 passed / 0 failed** (656 s); 실행 뒤 센티널 **비어 있음** |
|
||||
| 본 체크아웃 .mam/shim/herdr mtime | 전후 동일(1791473441); 실제 .mam/jobs 항목 수 불변 |
|
||||
- 이식성: Python 테스트 코드만(3.9 문법); W1 은 `git ls-files` 와 nested pytest 로 이 Mac 에서 통과.
|
||||
|
||||
## 2. 할 일 2 — 설계 판단
|
||||
- conftest 의 `pytest_configure`(:139-148)는 **다른** 체크아웃을 가리키는 WORKSPACE_ROOT 만 지우고 이 체크아웃을 가리키면 남긴다(lib.sh 가 어차피 유도하는 값). 위험은 "이 체크아웃 자신"(에이전트 pane 에서 main 체크아웃으로 스위트를 돌릴 때 = 실제 워크스페이스)이며, 그 근본은 lib.sh:3088 이 **source 시점마다** `_init_herdr_isolation`(:143-146) 으로 `$WORKSPACE_ROOT/.mam/shim/herdr` 를 쓰는 것이다(직접 확인: `source lib.sh && find_workspace_uuid` 만으로 센티널에 shim 생성).
|
||||
- `own_workspace_root` 를 autouse 로 두지 않은 이유(docstring: runner.py·mqtt_common 이 in-process 로 WORKSPACE_ROOT 를 읽음)는 타당하고, usefixtures 마크는 autouse 뒤·테스트 인자(mam_sandbox) 앞에 적용되므로 "mam_sandbox 와 테스트 자신의 지정이 이긴다"는 주장도 맞다. unittest.TestCase(test_workspace_scope)는 마크가 적용되지 않아 env 를 직접 지정한 것도 일관된다.
|
||||
- test_issue21_worktree_prereqs `_mutate`(:546-547)와 test_workspace_scope(:30-32)의 명시 지정, test_orc_onboard 의 `_scrubbed_env` 도 적절.
|
||||
|
||||
## 3. 놓친 모듈 (차단) — tests/test_o2_race_free_lock.py:196-203
|
||||
```
|
||||
196 def test_o2_12_run_loop_exits_on_lock_failure(tmp_path):
|
||||
200 cmd = ["bash", str(RUN_LOOP), "--creator", "dummy-agent", "--task", "test"]
|
||||
201 run_env = dict(os.environ)
|
||||
203 res = subprocess.run(cmd, …, cwd=str(tmp_path), env=run_env)
|
||||
```
|
||||
- run_loop.sh:17 이 lib.sh 를 source 하고, 이 테스트는 os.environ 을 그대로 물려주며(WORKSPACE_ROOT 미지정, cwd 만 tmp_path), 파일에 `own_workspace_root` 마크도 mam_sandbox 도 없다(:5 `import pytest` 는 이미 있음).
|
||||
- 재현(W1 과 같은 방법, HEAD 트리 사본): `WORKSPACE_ROOT=<사본>` 으로 이 테스트 1개 → 사본에 `.mam/shim/herdr` **생성**; `WORKSPACE_ROOT` 미설정으로도 **생성**(lib.sh 가 체크아웃을 유도); 파일 전체(22 passed)도 생성. 같은 방법으로 돌린 내 다른 후보 11개 파일 40개 테스트(test_issue30_stall_watch_stop, test_issue36_prompt_transport, test_issue19, test_a4, test_issue21_acceptance, test_issue20, test_issue34, test_issue23_24, test_b4, test_issue15, test_issue10_13)는 모두 깨끗했다 — 놓친 것은 이 모듈뿐이다.
|
||||
- 따라서 W1 의 "every module whose shells used to inherit the root" 목록(NODES, test_issue17_workspace_root_isolation.py:26-40)이 불완전하고, 커밋 8b47cc7 의 제목이 참이 아니다.
|
||||
- 수정(같은 방식): test_o2_race_free_lock.py 에 `pytestmark = pytest.mark.usefixtures("own_workspace_root")` 추가(o2_13 의 mam_sandbox 는 그대로 이김), W1 NODES 에 `tests/test_o2_race_free_lock.py::test_o2_12_run_loop_exits_on_lock_failure` 추가. 그러면 W1 이 이 회귀를 앞으로 잡는다.
|
||||
|
||||
## 4. 비차단
|
||||
- W1 은 모듈당 대표 노드 1개만 돌리므로 같은 모듈의 다른 테스트가 새로 lib.sh 를 source 하면 잡지 못한다 — 마크가 모듈 전체에 걸리므로 현재 설계에서는 문제없지만, 목록 유지가 수동이라는 한계는 적어 둔다.
|
||||
- 1회차 표의 두 파일(test_b19, test_version_consistency)은 이번에 마크로 덮였다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261004T044643Z-1b1440",
|
||||
"base_sha": "e1ed2708d835d712f5f3a9dfd51b8f66ca1ac447",
|
||||
"head_sha": "150b4937f816c6407be16fc4c37ab88e478927e7",
|
||||
"tree_sha": "afce44d2e0f91720d81e8d7d484f0e1358dee680",
|
||||
"review_commit": "150b4937f816c6407be16fc4c37ab88e478927e7",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "dcece792",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-04T04:54:16Z"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# 리뷰 리포트 — Job dcece792 (PR #47 2회차 델타, e1ed270..150b493)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261004T044643Z-1b1440 / base e1ed270 / head 150b493 / tree afce44d — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
|
||||
## 결론 요약
|
||||
codex R1(그리고 같은 내용인 본인 1회차 관찰 N1)이 닫혔다. 차단 결함 없음.
|
||||
|
||||
## 1. 범위
|
||||
`git diff --stat e1ed270 HEAD` 와 `git diff --stat 6af4a79 HEAD` 모두 tests/test_issue42_skill_blocks.py 한 파일(누적 +59/−3). 제품 코드·버전 변경 없음.
|
||||
|
||||
## 2. R1 종결
|
||||
| 지적 | 수정 (tests/test_issue42_skill_blocks.py) | 확인 |
|
||||
|---|---|---|
|
||||
| codex R1 / claude N1: 깨진 최상위 링크가 `Path.exists()` 때문에 skip | claude_skills_problems(:240-261): 부재 = `not exists() and not is_symlink()` 일 때만 None(skip). symlink 는 `exists() and resolve() == skills_dir.resolve()` 여야 함 | 검증 경로에 `.claude/skills -> ../nowhere` 를 만들어 실제 테스트 실행 → **fail** (1회차 head 에서는 skip 이었음) |
|
||||
|
||||
## 3. 실제 배치 탐침 (검증 경로 안 임시 .claude/skills, 매번 삭제)
|
||||
| 배치 | 결과 |
|
||||
|---|---|
|
||||
| 스킬별 링크 8개 + lib.sh·lib_py 링크(본 체크아웃과 동일) | pass |
|
||||
| 단일 링크 → ../.agents/skills | pass |
|
||||
| 깨진 최상위 링크(codex 탐침) | fail |
|
||||
| 스킬 링크 하나가 깨짐 | fail ("… missing or dangling") |
|
||||
| .claude/skills 가 일반 파일 | fail (각 스킬 "missing or dangling") |
|
||||
| 최상위 자기 참조 링크(`skills -> skills`) | fail (RuntimeError: Symlink loop, Python 3.9.6) — 관찰 참고 |
|
||||
시작 전 .claude 부재, 끝난 뒤 잔여물 없음·`git status --short --ignored` 비어 있음·HEAD 불변 확인. 본 체크아웃의 .claude/skills 는 건드리지 않았다.
|
||||
|
||||
## 4. 합성 배치 테스트
|
||||
- test_claude_skills_layouts(:271-300)는 실제 배치 검사(test_claude_skills_link_resolves_to_agents_skills, :264-268)와 **같은 함수** claude_skills_problems 를 부른다.
|
||||
- tmp_path 아래 상대 링크만 쓰고 외부 상태·시간·네트워크에 의존하지 않으므로 결정적이다. 6개 배치(absent/single/per_skill/wrong_skill/broken_top/broken_entry)가 모두 워크트리에서도 돈다.
|
||||
- 실행: 검증 경로에서 `tests/test_issue42_skill_blocks.py` **37 passed / 1 skipped** (1회차 31 + 합성 6; skip 은 실제 배치 테스트, 워크트리라 정상).
|
||||
|
||||
## 5. 비차단 관찰
|
||||
- 최상위 링크가 순환하면 :252 의 메시지용 `link.resolve()` 가 Python 3.9 에서 RuntimeError 를 던져 assert 실패 대신 예외로 끝난다. 어느 쪽이든 테스트는 실패하므로 잘못된 배치를 놓치지 않는다. 메시지를 `os.readlink(link)` 로 만들면 깔끔해진다(선택).
|
||||
- 복사본 디렉터리일 때 메시지가 원인을 직접 말하지 않는 점(1회차 N2)은 그대로이며 사소하다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,93 @@
|
||||
# PR #62 리뷰 — Issue #60 Linux 확인 후속 (job dd5c8c62)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261019T000805Z-975c4c/1`
|
||||
- identity: HEAD `6a4e385a78c8a561d5cf3644336de6a352ea9271`, tree `afa2c55925f93820337b69699c670c0b0ca90980` 일치. 작업 트리 clean, `.mam` 없음. base `1d7b7da`..head 8커밋. 판정은 base..head 전체에 대한 것이다.
|
||||
- 환경: macOS, `/usr/bin/python3` 3.9.6 와 `.venv` python 3.9.6, 둘 다 sqlite 3.51.0. 실제 opencode 데이터는 `ls`·`grep -c` 로만 봤다. 실제 DB 는 sqlite 로 열지 않았다.
|
||||
|
||||
## 결론
|
||||
|
||||
제품 코드에서는 차단 결함을 찾지 못했다. 다만 새 테스트 5개가 이 Mac 에서 항상 실패한다(B1). 이 리뷰의 중점이 macOS 정합성이고, 오케스트레이터의 전체 스위트도 이 Mac 에서 돈다. 그래서 NOT PASS 로 둔다. 고칠 곳은 fixture 몇 줄이고 재설계는 필요 없다.
|
||||
|
||||
## 차단
|
||||
|
||||
**B1. `test_a_query_reads_right_and_leaves_the_sidecars_alone[clean-*]` 5개가 macOS 에서 실패한다.**
|
||||
- 위치: `tests/test_issue60_linux_fixes.py:153-154`(`make("clean")`)과 `:175-181`.
|
||||
- 원인: macOS 시스템 SQLite 는 persistent WAL 로 동작해서, 마지막 연결을 닫아도 `-wal`·`-shm` 을 지우지 않는다. 임시 DB 에서 쓰기 연결을 닫은 뒤의 목록이 `['o.db', 'o.db-shm', 'o.db-wal']` 였다. 그래서 `_wal_writer(db, ws).close()` 뒤의 목록이 기대값 `["opencode.db"]` 와 다르고, 첫 assert(`make(state) == listing`)에서 실패한다. 제품 코드까지 가지도 않는다.
|
||||
- 재현: 검증 경로에서 `env -i …` 로 `tests/test_issue60_linux_fixes.py` 를 단독 실행하면 5 failed, 40 passed. 관련 18개 모듈 실행(아래)에서도 같은 5개만 실패했다.
|
||||
- 제품 코드는 맞다. 이 테스트의 `clean` 상태는 Linux 에서 닫힌 DB 의 모습(사이드카 없음)이다. git archive 사본에서 이 상태를 만들도록 close 뒤에 `-wal`·`-shm` 을 지워 보았다. 그러자 해당 서브셋 33개가 모두 통과했고, 아래 뮤테이션 6개도 모두 잡혔다.
|
||||
- 권고: `clean` 분기에서 close 뒤 남은 `-wal`·`-shm` 을 지운다. close 가 이미 checkpoint 하므로 행은 본 파일에 있다. 또는 Mac 의 실제 상태인 "쓰는 쪽 없이 사이드카 둘 다"는 이미 `leftover` 가 다루므로, 그 사실을 주석으로 남긴다.
|
||||
- 주의: 지금 상태의 Mac 에서는 `clean` 이 첫 assert 에서 멈춘다. 그래서 immutable 분기를 없애는 뮤테이션(M1)이 이 Mac 에서는 드러나지 않는다.
|
||||
|
||||
## 중점 항목 확인
|
||||
|
||||
**1. `_ro_connect` 세 갈래 (`opencode.py:13-36`).** 검증 경로 코드를 3.9 로 import 해 임시 DB 에서 확인했다. 사이드카를 직접 지워 Linux 와 같은 상태를 만들었다.
|
||||
|
||||
| 상태 | 결과 |
|
||||
|---|---|
|
||||
| 사이드카 없음 | 같은 상태에서 base 의 평범한 `mode=ro` 는 `unable to open database file` 로 실패한다. 그래서 base 의 `_descendants`·`db_rows_naming` 은 Mac 에서도 빈 결과였다. `_ro_connect`(immutable)는 행을 읽고 아무 파일도 만들지 않는다. |
|
||||
| writer 가 살아 있음(둘 다 있음) | WAL 에만 있는 행까지 읽는다. 목록은 그대로다. |
|
||||
| `-shm` 없는 `-wal` | 커밋된 행을 읽는다. 원본 두 파일의 mtime 은 그대로였고 `-shm` 도 생기지 않았다. 같은 상태에서 평범한 `mode=ro` 는 `-shm` 을 만들었다. 디렉터리가 읽기 전용(0555)이어도 읽힌다. |
|
||||
| DB 없음 | `OperationalError`. 파일은 생기지 않는다. base 의 `sqlite3.connect(db)` 는 빈 DB 를 만들었으므로 개선이다. |
|
||||
|
||||
- 실제 Mac 의 상태: 실제 디렉터리에는 서비스가 떠 있어 `opencode.db`·`-shm`·`-wal` 이 모두 있다(`ls`). persistent WAL 이 기본인 Mac 에서는 "둘 다 있음 → mode=ro" 갈래가 대부분일 것이다. 다만 bun 이 시스템 SQLite 를 쓰는지는 확인하지 않았다.
|
||||
|
||||
**2. `_DB_PATHS` 캐시 (`opencode.py:39-40, 139-161`).**
|
||||
- 키는 `(bin, ctx.home_dir, _data_home(ctx))` 다. `_run` 이 넘기는 HOME·XDG_DATA_HOME 과 같은 값이라, 다른 HOME 이나 XDG 에 다른 경로를 돌려주지 않는다.
|
||||
- 실패한 `debug paths` 는 저장되지 않는다. 바이너리가 없으면(`bin_path` None) 캐시를 쓰지 않는다.
|
||||
- 테스트 간 누출: 이 모듈은 autouse fixture 로 캐시를 비운다. 다른 모듈은 tmp_path 별 HOME 이라 키가 겹치지 않는다. 18개 모듈을 한 프로세스에서 실행했을 때 B1 외의 실패는 없었다.
|
||||
|
||||
**3. auth_ok (`opencode.py:317-321`, `__main__.py:120-127`, `create_session.sh:145-151`).**
|
||||
- 다른 에이전트의 분기와 adapter 는 바뀌지 않았다. `auth-ok` 를 부르는 곳은 opencode 분기뿐이다.
|
||||
- `python -m lib_py.agents auth-ok opencode` 를 `/usr/bin/python3` 3.9 로 실행했다. PATH 에 opencode 가 없으면 rc 1, `~/.opencode/bin` 이 있으면 rc 0, 인자가 없으면 rc 1(`unknown agent ''`).
|
||||
- PYTHONPATH 는 lib.sh 가 export 한다(`lib.sh:25`). 같은 파일의 `facts`·`spawn-spec` 호출과 같은 방식이다.
|
||||
- create 의 사전 검사로 충분하다. 바로 위 `command -v "$AGENT"`(`:117`)가 이미 PATH 의 바이너리를 요구한다.
|
||||
|
||||
**4. `input_placeholder_pattern` (`opencode.py:75-79`, `input_region.py:83-85`).**
|
||||
- 홈 화면 fixture 두 개(140열, 잘린 예시가 있는 60열)는 모두 빈 입력으로 읽힌다. 1.x 캡처도 그대로 빈 입력이다. 다른 adapter 는 None 이다(테스트로 고정).
|
||||
- fullmatch 라 `Ask anything… "x" and fix it` 처럼 뒤에 글이 붙은 입력은 그대로 읽힌다.
|
||||
|
||||
**5. stop 문구 (`stop_session.sh:242`).** 메시지만 바뀌었고 동작은 같다. 테스트 2개(exit 됨 / 살아 있어 kill)가 통과했다.
|
||||
|
||||
**6. Python 3.9 와 셸.**
|
||||
- 바뀐 Python 5개 파일이 3.9 에서 파싱되고, 제품 모듈은 3.9 로 import 해 실행했다. 새 테스트는 `from __future__ import annotations` 를 쓴다.
|
||||
- `create_session.sh`·`stop_session.sh` 가 `/bin/bash -n`(3.2)을 통과한다. `git diff --check 1d7b7da..HEAD` 는 깨끗하다.
|
||||
|
||||
## 테스트 실행 (검증 경로)
|
||||
|
||||
조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad, `-p no:cacheprovider`, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
|
||||
대상 18개 모듈: issue60_linux_fixes, issue60_opencode_v2, a4, issue50_dispatch, issue30_stall_watch(+_stop), issue31, tier1, tier2, c1_tui_readiness, issue5_phase0/phase2b, issue24, issue6, uuid_target, issue55, issue57, issue21_worktree_prereqs.
|
||||
|
||||
- 결과: **5 failed(B1 의 5개뿐), 623 passed, 2 skipped**, 29분.
|
||||
- skip 2개: `/proc` 전용 테스트와, PATH 에 opencode 가 없어 건너뛴 실바이너리 계약 테스트.
|
||||
- 부작용 확인:
|
||||
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||
- 실제 `~/.local/share/opencode` 목록은 실행 전후가 같았다.
|
||||
- 실제 log 의 `debug` 줄 수는 2 → 2.
|
||||
- 남은 opencode 프로세스는 없었다(사용자 서비스 제외).
|
||||
- #55 가드 오탐도 없었다.
|
||||
|
||||
뮤테이션: git archive 사본에 B1 fixture 수정을 넣고 진행했다. 대상은 issue60_linux_fixes 와 a4 이며, 실제 프로세스를 띄우는 stop·create 2개는 뺐다. 모두 잡혔다.
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| M1 immutable 분기를 `mode=ro` 로 | 5 failed |
|
||||
| M2 `wal and shm` 를 `wal` 로 | 5 failed |
|
||||
| M3 캐시 사용 끔 | 1 failed |
|
||||
| M4 잘린 예시 허용(`"?`) 제거 | 2 failed |
|
||||
| M5 fullmatch 를 match 로 | 4 failed |
|
||||
| M6 auth_ok 를 항상 True 로 | 3 failed |
|
||||
|
||||
## 비차단
|
||||
|
||||
- **N1. immutable 경쟁.** `-wal` 없음을 판단한 뒤 연결이 쓰이는 동안 writer 가 생기면 결과가 틀릴 수 있다. 임시 DB 에서 재현했다.
|
||||
- (a) 판단 직후 writer 가 한 행을 커밋하면, immutable 연결은 1/2 만 본다.
|
||||
- (b) 열린 immutable 커서 아래에서 writer 가 지우고 다시 쓰고 checkpoint 하면, 예외 없이 옛 행과 새 행이 섞인 20119행이 나왔다(옛 20000행, 새 30000행).
|
||||
- 쿼리가 짧아(LIMIT 20, 단건 조회) 실제 창은 ms 수준이다. persistent WAL 인 Mac 에서는 이 갈래 자체가 드물다.
|
||||
- 원하면 이렇게 줄일 수 있다: 쿼리 뒤 `-wal` 이 여전히 없고 본 파일의 mtime·size 가 그대로인지 확인하고, 아니면 `mode=ro` 로 한 번 다시 읽는다.
|
||||
- **N2. 캐시는 프로세스 수명 동안 유지된다.** 같은 경로에서 바이너리를 업그레이드한 경우, 오래 도는 `reconcile --subscribe` 는 예전 `debug paths` 결과를 계속 쓴다. 경로 규칙이 바뀌는 업그레이드에서만 문제가 된다.
|
||||
- **N3. 사전 검사가 약해졌다.** create 의 opencode 검사는 사실상 `command -v` 와 같아졌다. 깨진 바이너리도 통과하고, 실패는 이후 준비 대기 단계에서 드러난다. 2.x 에 로그인이 없다는 전제와 맞는 완화로 본다.
|
||||
- **N4. 안내 문구와 같은 모양의 초안.** 사용자가 정확히 그 모양으로 친 입력(`Ask anything...`, `Ask anything… "fix the tests"`)은 빈 입력으로 읽힌다. 확인한 결과 둘 다 `''` 였다. 실사용에서 생길 가능성은 매우 낮다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260923T220355Z-76dd1d",
|
||||
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
|
||||
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
|
||||
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "df626811",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-23T23:35:19Z"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# 🔍 Review Report — PR #35 델타 리뷰 2회차 (job `df626811`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||
- **대상 델타**: `f4ba205..05b0ea4` (1커밋 `05b0ea4`, 6파일 `+76/−5`, `git diff --check` 클린), base `main 1e78dae` (v4.7.1).
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T220355Z-76dd1d/1`. **identity 확인**: `HEAD = 05b0ea47…a465`, `HEAD^{tree} = 8f5e8b04…22f3` — 브리프와 일치.
|
||||
- **1회차 내 판정**: PASS (job `adc0ff3c`). 이번에는 codex 탐침 2건의 정정이 실제로 닫혔는지와 부작용을 본다.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 함수 추출 실험과 되돌린 사본 실행은 scratchpad 에서만, pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**두 정정은 탐침을 닫고, 1회차 판정을 뒤집을 요소는 없다.** P2 의 `continue` 는 S5/S5b 의 쿨다운 계약을 그대로 유지하면서 같은 화면의 다음 다이얼로그를 보게 하고, P1 의 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 는 "플래그 > 환경 > 기본 1" 의 우선순위를 문서대로 만든다. 비차단 관찰 2건(G1·G2).
|
||||
|
||||
## 1. P2 — `_mam_dialog_pick` 의 `continue` (항목 1)
|
||||
|
||||
`lib.sh:2954-2958`. 함수만 추출해 codex 어댑터의 다이얼로그 2개(신뢰 창 idx0 → `Enter`, 업데이트 안내 idx1 → `2`)와 **두 창이 함께 있는 화면**으로 7가지 상태를 돌렸다(cooldown 5 s):
|
||||
|
||||
| # | 상태 | 결과 | 의미 |
|
||||
|---|---|---|---|
|
||||
| 1 | 기록 없음, 자동 신뢰 on | `send\|0\|Enter` | 신뢰 창이 먼저(어댑터 순서) |
|
||||
| 2 | 신뢰 창을 1 s 전에 보냄(쿨다운 중), on | **`send\|1\|2`** | **P2 정정**: 쿨다운 중인 창을 건너뛰고 안내에 답한다. 옛 코드는 여기서 `exit 1` 로 아무것도 안 했다 |
|
||||
| 3 | 기록 없음, 자동 신뢰 off | `skip\|0\|` | 신뢰 창은 사람 몫, skip 기록 |
|
||||
| 4 | skip 1 s 뒤, off | **`send\|1\|2`** | **P2 정정**: skip 의 쿨다운이 안내를 가리지 않는다 |
|
||||
| 5 | 두 창 모두 쿨다운 중 | 없음(rc 1) | **S5 유지**: 쿨다운 안에서는 재전송 없음 |
|
||||
| 6 | 두 창 모두 쿨다운 만료(+5 s) | `send\|0\|Enter` | **S5b 유지**: 만료 후 1회 재시도, 다시 신뢰 창부터 |
|
||||
| 7 | off, 만료 | `skip\|0\|` | 경고가 쿨다운마다 1회 |
|
||||
|
||||
**키 순서·횟수**: 핸들러는 한 폴링에 하나만 고르므로(`_mam_dialog_pick` 은 첫 actionable 항목에서 종료), 두 창이 한 화면에 있으면 **1회차 폴링에 `Enter`, 다음 폴링(1 s 뒤)에 `2`** 순으로 각 1회 간다. S5f 가 `["Enter", "2"]` 를, S6c 가 `["2"]` + 경고를 그대로 고정한다. 쿨다운은 **인덱스별**(`state[idx]`)이라 서로 간섭하지 않는다.
|
||||
|
||||
## 2. P1 — `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` (항목 2)
|
||||
|
||||
`create_session.sh:72,87-88,96` 과 `resume_session.sh:33,42-43,57` 이 같은 3단 구조(초기값 ← 환경, 플래그 arm 이 덮어씀, 마지막에 `export`)다. 세 줄을 그대로 옮긴 하네스로 8가지를 돌렸다:
|
||||
|
||||
| 환경 `MAM_AUTO_TRUST` | 플래그 | 핸들러가 받는 값 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 없음 | – | `1` (on) | 기본 |
|
||||
| `0` | – | `0` (**off**) | **P1 정정**: 문서의 환경변수 형식이 이제 닿는다(옛 코드는 `1` 로 덮어썼다) |
|
||||
| `0` | `--auto-trust` | `1` (on) | 플래그 > 환경 |
|
||||
| `1` | `--no-auto-trust` | `0` (off) | 플래그 > 환경 |
|
||||
| 없음 | `--no-auto-trust` | `0` (off) | |
|
||||
| `yes` / `false` | – | 그대로 전달 → 핸들러 `!= "0"` 규칙으로 **on** | `_mam_dialog_pick` 도 같은 규칙(`MAM_DLG_TRUST != "0"`)이라 두 층이 일치한다 |
|
||||
| `''`(빈 문자열) | – | `:-1` 이 빈 값을 미설정으로 봐 `1` (on) | 핸들러의 `${MAM_AUTO_TRUST:-1}` 와 같은 해석 |
|
||||
|
||||
즉 "0 만 off, 나머지는 전부 on" 이 스크립트·핸들러·picker 세 층에서 동일하다(`lib.sh:2986`, picker 의 `!= "0"`). create SKILL.md `:119-120` 이 환경변수 형식과 "플래그 우선"을 명시한다. **G1 (비차단)**: `false`/`no` 가 on 으로 해석되는 것은 일관되지만 직관에 반한다 — 문서에 "`0` 만 끈다" 를 한 마디 적어 두면 좋다. resume SKILL.md 는 `:107` 에서 `MAM_AUTO_TRUST=0` 형식은 적지만 "플래그가 환경보다 우선" 은 create 쪽에만 있다(G2, 사소).
|
||||
|
||||
## 3. 테스트 (항목 1·3)
|
||||
|
||||
### 3-1. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 `lib.sh`·`create_session.sh`·`resume_session.sh` 세 파일만 `f4ba205` 판으로 되돌리고 새 회귀와 이웃 시험을 `-k "s6c or s5f or r3b or c3 or test_s5_ or s5b or test_s6_"` 로 돌렸다:
|
||||
|
||||
```
|
||||
4 failed, 7 passed, 70 deselected in 153.65s
|
||||
FAILED test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it ← P2
|
||||
FAILED test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one ← P2
|
||||
FAILED test_r3b_…_on_resume[0-flag0-0] (환경변수만, 플래그 없음) ← P1
|
||||
FAILED test_c3_…_on_create[0-flag0-0] (환경변수만, 플래그 없음) ← P1
|
||||
```
|
||||
|
||||
실패 4건이 정확히 두 탐침의 재현 사례다. 통과한 7건 — R3b/C3 의 "플래그가 환경을 이긴다" 4사례(플래그는 옛 코드에서도 동작했다), S5·S5b(쿨다운 계약), S6(신뢰 창 skip + 안내 응답, 한 화면에 하나씩) — 은 정정 전후로 **변하지 않아야 하는** 계약이고, 실제로 변하지 않았다. 즉 `continue` 가 S5/S5b 를 깨지 않았다는 것이 되돌린 사본과 PR 트리 양쪽에서 성립한다(PR 트리 쪽은 §3-2).
|
||||
|
||||
### 3-2. PR 트리(검증 경로)에서의 실행
|
||||
|
||||
```
|
||||
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_c1_tui_readiness.py tests/test_b19_headless_reconcile_fixes.py
|
||||
→ 120 passed in 535.40s (0:08:55) failed 0 · skipped 0
|
||||
같은 -k 부분집합(새 회귀 4 + 이웃 7) 단독 → 11 passed in 148.21s
|
||||
```
|
||||
|
||||
- 다이얼로그 핸들러를 건드리는 4파일 전부 통과. `c1`·`b19` 의 "프레임당 1회"·`Yes, try it` 가드도 유지된다.
|
||||
- 실제 `.mam/jobs` 전후 **차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `05b0ea4`), 본 체크아웃 `main 1e78dae`.
|
||||
- 시간: 이 호스트에서 다른 스위트 2개가 동시에 돌고 있어 resume 시험 6건이 각 37~41 s 였다(1회차 F6 그대로; R3b 3사례가 더해져 +2 분).
|
||||
|
||||
## 4. 병합 차단 여부 (항목 3)
|
||||
|
||||
1회차 PASS 를 뒤집을 요소는 없다. 델타는 세 줄의 소스 변경(`lib.sh:2958` `continue`, `create_session.sh:72`·`resume_session.sh:33` 의 환경 기본값)과 문서 한 줄, 회귀 4건이며, 두 탐침의 재현 사례만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| G1 | `MAM_AUTO_TRUST` 는 `0` 만 끈다(`false`/`no` 는 on). 세 층(스크립트·핸들러·picker)이 일치하므로 결함은 아니지만 문서에 한 마디 있으면 좋다 | `create_session.sh:72`, `lib.sh:2986`, `_mam_dialog_pick` 의 `!= "0"` |
|
||||
| G2 | "플래그가 환경보다 우선" 은 create SKILL.md(`:119-120`)에만 있고 resume SKILL.md(`:107`)에는 환경변수 형식만 있다 | 사소 |
|
||||
|
||||
1회차 F1(codex 수락 키와 브리프 서술의 차이)은 이 델타의 범위 밖이며 그대로 열려 있다.
|
||||
|
||||
## 5. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 05b0ea4… / 8f5e8b0…
|
||||
# picker 단독: 신뢰 창 쿨다운 중에도 안내에 답하는지
|
||||
eval "$(sed -n '/^_mam_dialog_pick() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
|
||||
D='[["Do you trust the contents of this directory|1\\. Yes, continue",["Enter"],"trust"],["1\\. Update now|Skip until next version",["2"],"notice"]]'
|
||||
S=$'Do you trust the contents of this directory?\n > 1. Yes, continue\nUpdate available\n 1. Update now\n 2. Skip\n 3. Skip until next version'
|
||||
_mam_dialog_pick "$S" "$D" 1 "0:1000" 1001 5 # send|1|2 (정정 전: 출력 없음)
|
||||
_mam_dialog_pick "$S" "$D" 1 "0:1000 1:1000" 1002 5 || echo nothing # S5 유지
|
||||
# 되돌린 사본에서 회귀가 실패하는지: lib.sh/create/resume 를 f4ba205 판으로 교체 → -k "s6c or s5f or r3b or c3 ..." → 4 failed / 7 passed
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,65 @@
|
||||
# 리뷰 리포트 — Job e93cf9d4 (PR #51, refactor/issue-17-item4)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261007T142403Z-10d77f / base 7209750 / head 518beda / tree 137b4e4 — 검증 경로에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
|
||||
## 결론 요약
|
||||
동작 불변 계약이 지켜졌다. 옛 bash 판정(tests/data/issue17_item4_old_decisions.sh = 7209750 의 함수)과 새 wrapper.py 를 경로별로 대조해 차이를 찾지 못했고(옛 코드의 따옴표 경로 버그 하나는 새 코드에서 고쳐짐 — 개선), 지정 9개 파일이 이 macOS·Python 3.9·bash 3.2 에서 전부 통과한다. 차단 결함 없음. 비차단 관찰 5건(§8).
|
||||
|
||||
## 1. 동작 불변 — 경로별 대조
|
||||
| 옛 bash (old_decisions.sh) | 새 Python (wrapper.py) | 판단 |
|
||||
|---|---|---|
|
||||
| _map_sub_rc :72-90 | sub_status :58-66 | 같음. rc 3 은 디스크 종결 상태가 이김, 그 외 코드는 timeout |
|
||||
| 구독자 조기 종료 판정 `[ $sub_exit -eq 0 ] \|\| [ … -eq 3 ]` | subscriber_up :69-70 | 같음 |
|
||||
| _rc_for :92-101 + 호출부의 `MAM_DELEGATE_FAIL_CLOSED=0 → exit 0` | exit_code :77-78, fail_closed :73-74 | 같음. 빈 값은 1 로 보는 `${…:-1}` 와 `or "1"` 일치 |
|
||||
| cmd_wait rc 4→2 :261 | wait_rc :81-82 | 같음 |
|
||||
| start_stall_watch 의 감시 여부 :30-36 | stall_watch :89-99 | 같음. 메시지도 동일 |
|
||||
| finalize_unfinished 앞부분: FAIL_CLOSED=0·completed 면 반환, stall.out 꼬리 3줄, 디스크 종결이면 FINAL=디스크(completed 면 반환) :105-123 | finalize_begin :161-187 (`keep` / `done completed` / `go\|probe <final> <evidence_until> <deadline>`) | 같음 |
|
||||
| broker_unavailable: 마감까지 ≤3 s 간격 재관측, 매 회 시계로 마감 계산, 마감 도달 시 wall :128-146 | finalize_end :201-216 | 같음(`_sleep` 이 0.5 s 단위로 쪼개 소유자를 확인할 뿐, 대기 총량은 같다) |
|
||||
| timeout: evidence_until 는 진단 **전**에 계산, 마감까지 디스크 재관측, 디스크 heartbeat 가 창을 다시 열고, 창이 지나면 종료, 마감 도달 시 wall :149-194 | finalize_begin 이 evidence_until 계산(:181) → bash 가 진단 프로브(래퍼 :137-142) → finalize_end :217-238 | 같음. 진단 순서도 같다 |
|
||||
| 라벨: sub_rc 4 → wall, wall 이고 phase≠inject 면 wall_clock_timeout, preflight/idle_wait 는 cancelled + session_busy 또는 PREFLIGHT_REASON :199-209 | :242-251 | 같음 |
|
||||
| close rc 4(토큰 불일치) → 이벤트 억제, FINAL 그대로 :216-224 | :256-262 | 같음 |
|
||||
| close 뒤 디스크 completed 면 completed :226-232 | :263-264 | 같음 |
|
||||
| direct 만 이벤트: cancelled 또는 (sub_rc≠1 또는 inject) 이면 error(inject 는 agent_blocked) :234-245 | :266-272 | 같음. 토큰 인자도 동일 |
|
||||
| registry.py close 출력 `2>&1` 을 stdout 으로, publish_event stderr 는 버림 | _run merge=True/False :190-193, 답은 fd 3 | 같음 — 래퍼가 `3>&1 1>&4` 로 stdout 을 원래 stdout 으로 보낸다(래퍼 :145-149). subprocess 기본 close_fds 로 fd 3 은 자식에 새지 않는다 |
|
||||
- 비교 테스트(test_c_*)가 status×sub_rc×phase 전 조합, 토큰 불일치(CLOSE_RC=4), FAIL_CLOSED=0, remote·human·loop·discuss, 늦은 completed, heartbeat 유예가 마감까지 가는 경우, 래퍼 사망(KILL·미회수 좀비)을 옛·새 양쪽으로 돌린다.
|
||||
- 직접 돌려 본 미포함 경로: registry 경로에 작은따옴표(`reg it's`)가 있을 때 옛 `_disk_status` 는 경로를 Python 소스에 끼워 넣어 `""` 를 내고(rc 3 + 디스크 completed 를 broker_unavailable 로 오판), 새 sub_status 는 `completed`. 옛 버그가 고쳐진 차이이며 계약 위반이 아니다.
|
||||
|
||||
## 2. bash ↔ Python 경계
|
||||
- `_decide`(래퍼 :97-99)는 `"$@"` 로 인자를 그대로 넘긴다. 빈 값(`--dispatch-token ""`, `--phase ""`, `--preflight-reason ""`)은 argparse 기본값과 같다. `-` 로 시작하는 값이 들어갈 수 있는 인자는 PREFLIGHT_REASON 뿐인데 값은 `target_not_found` 하나(래퍼 :850). `${START_TIME:+--start-time "$START_TIME"}` 는 빈 START_TIME 이면 인자를 빼고 Python 이 now 를 쓴다 — 옛 `${START_TIME:-$(date +%s)}` 와 같다.
|
||||
- 한 줄 출력: finalize-begin 은 stdout 에 답 한 줄만, 진단은 stderr. finalize-end 는 fd 3 으로 답(fd 3 이 닫혀 있으면 stdout).
|
||||
- 래퍼에 판정이 남지 않았다: test_s_the_wrapper_carries_no_moved_decision(주석 제외 소스에 _map_sub_rc·_rc_for·FINALIZE_CLOCK·`${MAM_DELEGATE_FAIL_CLOSED`·SUB_RC 비교·라벨 문자열 없음, lib_int.sh 에 BASH_REMATCH 없음).
|
||||
- knob 환경변수: 옛 bash 는 셸 변수로, 새 Python 은 환경으로 읽는다. 래퍼가 이 두 knob 을 대입하지 않고 .mam.env 는 `export` 줄로 들어오므로(래퍼 :37-45, envfile.py :116) 차이가 없다.
|
||||
- Python 실패 시: 관찰 N2.
|
||||
|
||||
## 3. finalize-end 의 --watch-pid
|
||||
- `_check_owner` 가 두 대기의 매 반복(0.5 s 단위)과 close 직전(:256), 이벤트 직전(:267)에 래퍼 생존을 본다. 사라졌으면 OwnerGone → 닫기·발행 없이 종료(:338-339).
|
||||
- `_alive`(:122-137): kill(0) 실패면 죽음, 성공이면 `ps -o stat= -p` 가 `Z…` 이거나 빈 출력이면 죽음. macOS 와 Linux 모두 좀비는 `Z`(macOS `Z`, Linux `Z`/`Z+`)로 시작하고, 사라진 pid 는 빈 출력이다. ps 를 못 돌리면 kill(0) 결과를 믿는다(살아 있음) — 닫는 쪽이 아니라 기다리는 쪽이지만, 래퍼가 사라진 뒤라면 대기는 마감에서 끝나고 닫기가 일어날 수 있다(ps 가 없을 때만, 비현실적).
|
||||
- 래퍼에 TERM trap 이 없으므로(trap 은 부트스트랩 EXIT 용 :916-942 뿐) TERM 이 `$(…)` 대기 중 지연되는 문제는 없다. 래퍼가 TERM/KILL 로 사라지면 고아가 된 Python 이 0.5 s 안에 알아채고 끝난다 — 옛 bash 루프가 래퍼와 함께 끝나던 것과 같은 결과. 테스트 test_c_finalize_ends_with_its_wrapper / _with_an_unreaped_wrapper / test_c_an_unreaped_wrapper_leaves_the_real_record_alone 통과.
|
||||
- 정상 경로 지연: 확인마다 ps 한 번(수 ms). 대기의 총길이는 옛과 같다.
|
||||
|
||||
## 4. 정수 규칙 (lib_py/ints.py)
|
||||
- positive_int 는 runner.py 의 옛 _positive_int 를 그대로 옮긴 것이고(diff 상 동일 본문), 옛 bash mam_int 와도 같은 규칙이다: ASCII 공백 6종만 앞뒤 허용, ASCII 숫자만(`٥` 거부, 로케일 무관), 선행 0 제거 뒤 9자리 이하, 최솟값. knob 의 경고 문구도 동일. test_c_mam_int_and_knob 이 C·en_US.UTF-8 두 로케일에서 옛·새를 비교하고 통과.
|
||||
- lib_int.sh 를 lib.sh 없이 source 하는 diff_collect.sh·loop_lock.sh: `_MAM_INTS_PY` 를 BASH_SOURCE 기준으로 잡으므로 동작한다(test_issue21_loop_b l15 는 그 배치를 재현하도록 lib_py/ints.py 도 복사). 비용·실패는 N3.
|
||||
|
||||
## 5. 수정된 정적 테스트 4개
|
||||
약해지지 않았고 오히려 강해졌다: 각 테스트가 래퍼 소스의 `_decide …` 호출 문자열을 계속 고정하면서, 옮겨진 표 자체(EXIT_CODES, rc 0~5 의 sub_status, rc 3 의 디스크 우선, SUB_RC_* 와 래퍼 상수의 일치)를 실제 함수로 검사한다. test_issue26_ints 의 NOT_ARITHMETIC 에서 두 knob 을 뺀 것은 그 knob 들이 셸에서 사라졌기 때문이며 정적 가드가 재유입을 막는다.
|
||||
|
||||
## 6. 성능
|
||||
- `_decide` 한 번 ≈ 50 ms(5회 0.25 s), 그중 약 29 ms 는 `lib_py.delegation` 패키지 import(N1). 잡 하나당 호출은 정상 경로에서 3~5회(sub-status, exit-code, stall-watch, 미완료 시 finalize-begin/-end).
|
||||
- `mam_int`/`mam_int_knob` 는 호출당 ≈ 47 ms(10회 0.47 s; 옛 bash 는 ≈ 0.1 ms). 래퍼 기동 시 knob 4~5개 + 플래그, send_keys 경로의 knob 몇 개. 모두 폴링 루프 밖(마감 계산은 1회)이라 잡당 1 s 미만이며 에이전트 턴 시간에 비해 무시할 수준.
|
||||
|
||||
## 7. 이식성
|
||||
이 macOS·/usr/bin/python3 3.9.6·bash 3.2 에서 지정 9개 파일 통과. wrapper.py·ints.py 는 3.9 문법(`from __future__ import annotations`, typing.List), /proc 없음(ps 사용), str.isascii(3.7+).
|
||||
|
||||
## 8. 비차단 관찰
|
||||
- **N1** wrapper.py 머리말의 "Stdlib only" 와 달리 `python -m lib_py.delegation.wrapper` 는 패키지 `lib_py/delegation/__init__.py` 를 먼저 import 해 service → mqtt_common → **paho** 를 싣는다(paho 없는 python3 에서 `ModuleNotFoundError: paho` 확인). 래퍼의 `$PY` 는 원래 paho 가 필요하므로 동작에는 문제없지만, 문서 정정이나 패키지 밖 모듈(ints.py 처럼 경로 실행)로 두면 호출당 ~29 ms 도 줄어든다.
|
||||
- **N2** `_decide` 가 실패하면(모듈 import 오류 등) `exit "$(_decide exit-code …)"` 는 `exit ""` → bash 3.2 에서 rc 255("numeric argument required"), finalize 도 실패해 잡이 열린 채 남는다. 옛 코드도 같은 `$PY` 로 registry.py 를 돌렸으므로 실질 회귀는 아니지만, `exit "${rc:-1}"` 같은 기본값이 있으면 fail-closed 코드(1)가 보장된다.
|
||||
- **N3** lib_int.sh 는 `$PY` 가 아니라 PATH 의 `python3 -I` 를 쓴다. python3 가 없으면 mam_int_knob 이 아무것도 출력하지 않아(옛 bash 는 항상 기본값 출력) `$(( $(date +%s) + $(mam_int_knob …) ))`(lib.sh:2783 등)가 산술 오류가 된다. MAM 은 python3 를 이미 필수로 요구하므로 현실적 위험은 낮다.
|
||||
- **N4** 옛 `_disk_status` 의 작은따옴표 경로 버그(§1)가 새 코드에서 고쳐졌다 — 동작 차이지만 개선.
|
||||
- **N5** 브리프·PR 설명의 "0.2 s 마다 확인"과 달리 코드는 0.5 s(`_sleep`, :145-153). 설명 정정만 필요.
|
||||
|
||||
## 9. 테스트
|
||||
검증 경로에서 지정 9개 파일(pr-51 .venv, Python 3.9.6): **604 passed / 1 skipped / 0 failed** (1009 s, rc 0). skip = test_issue30_stall_watch.py:340 "real process trees via /proc"(Linux 전용, 기존). MQTT_* unset, .mam.env 미export. 실제 .mam/jobs 항목 수 변화 없음(1052 → 1052).
|
||||
|
||||
[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]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261007T224638Z-cf1efb",
|
||||
"base_sha": "56d0bfda54e4f4fe1ccc138cc262edfb14cfb4a3",
|
||||
"head_sha": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"tree_sha": "b805d9f9e6639c7359115ea9336eac95d7ab85bc",
|
||||
"review_commit": "65b78b6ec44e44904afe34915563522d4631767f",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "f0f131cd",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T01:16:15Z"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# 리뷰 리포트 — Job f0f131cd (Issue #17 후속 2회차 델타, 8241bd5..65b78b6)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (claude)
|
||||
- 리뷰 identity: loop-20261007T224638Z-cf1efb / base 56d0bfd / head 65b78b6 / tree b805d9f — 검증 경로(…/2)에서 `git rev-parse HEAD 'HEAD^{tree}'` 일치, 미커밋 변경 없음
|
||||
- 범위: 커밋 2개 — 541c3fe(agy 지적: 동결 리터럴을 `_decided`/`_exit_for` 로), 65b78b6(codex R1: 실패한 yes/no 판정은 "no" 가 아니다). wrapper.py +24/−4, 래퍼 +18/−4, 테스트 3개. 버전·VERSIONS·문서 변경 없음(브리프대로).
|
||||
|
||||
## 결론 요약
|
||||
codex R1 이 닫혔고, 그 수정이 1회차에 내가 남긴 N1(stall-watch 판정 실패 시 조용히 감시 생략)도 함께 닫는다. 정상 경로(yes/no 두 분기 모두)의 동작은 불변. 새 테스트 15건이 1회차 head 코드에서 실제로 실패하며, 8건은 codex 가 지적한 바로 그 증상(rc 0)으로 실패한다. 차단 결함 없음.
|
||||
|
||||
## 1. 지적 종결
|
||||
| 지적 | 수정 | 확인 |
|
||||
|---|---|---|
|
||||
| codex R1: `_decide subscriber-up`/`stall-watch` 를 rc 로 읽어, 판정 **실패**(rc 1·127, 빈 답)가 "no" 와 구분되지 않음. subscriber-up 에서는 rc 0 으로 끝난 구독자가 "not up" → "died early" 경로 → sub-status 0 = completed → **submit 이 0 으로 종료** | wrapper.py:8-17, :335-340, :344-353 — 두 yes/no 서브커맨드에 `--print`(yes/no 출력 + rc 0). 래퍼 :121-131 `_decided_yes` 가 `_decided` 로 답을 받아 `yes`/`no` 외에는 `_decision_failed` → exit 1. 호출 3곳(:396, :568, :730) 교체 | 1회차 head(8241bd5) 래퍼+wrapper.py 를 넣은 사본에서 test_f_a_failed_yes_no_decision_ends_the_submit_with_1 9건 **모두 실패** — subscriber-up 6건은 `assert 0 == 1`(codex 의 증상 그대로), stall-watch 3건은 오류 문구 없이 정상 종료. 현재 코드에서는 9건 모두 rc 1 + "could not decide" |
|
||||
| claude N1(1회차): stall-watch 판정 실패가 `\|\| return 0` 으로 조용히 감시만 끔 | 위와 같음 — "no" 는 여전히 `return 0`(감시 안 함), 실패는 exit 1 | stall-watch-direct-{error,gone,empty} 통과 |
|
||||
| agy: test_issue5_phase1_delegation·test_tier1_unit 의 동결 리터럴이 옛 `_decide sub-status`/`exit "$(_decide exit-code` 문자열을 가리킴 | `_decided job_status sub-status "$sub_rc"`, `_exit_for "$job_status"` 로 갱신 | 두 파일 통과(아래) |
|
||||
|
||||
## 2. 정상 경로 불변
|
||||
- rc 형식은 그대로 남아 있다(`--print` 없이 subscriber-up 1 → rc 1 확인; test_u_yes_no_print 가 두 형식을 함께 고정).
|
||||
- test_f_yes_and_no_keep_their_branches(direct·loop × up/not-up): "yes" 는 진행, "no" 는 "subscriber died early" 경로로 가고 direct 의 종료 코드는 0/1 — 옛 코드에서도 통과(4 passed)하고 새 코드에서도 통과한다 = 분기 결과 불변. 옛·새 비교 테스트(test_c_*) 전부 통과.
|
||||
- `_decided_yes` 는 `if …; then`(:396, :568)과 `… || return 0`(:730) 안에서 직접 호출되며 서브셸이 아니므로 `exit 1` 이 래퍼를 끝낸다. `--print` 를 위치 인자 뒤에 붙여도 argparse 가 받는다(3.9 에서 직접 확인: `stall-watch remote:r claude --print` → stderr 진단 + stdout `no`, rc 0).
|
||||
- `stall_watch` 의 진단 메시지는 전과 같이 stderr 로만 나가 stdout 의 한 줄 답을 더럽히지 않는다.
|
||||
|
||||
## 3. 정적 가드
|
||||
test_s_no_printed_decision_is_read_raw 가 `_decide (subscriber-up|stall-watch)` 의 rc 읽기 재유입을 막고 `_decided_yes` 호출 수(2+1)를 고정한다. 옛 코드에서 실패, 새 코드에서 통과.
|
||||
|
||||
## 4. 이식성
|
||||
`/bin/bash -n`(3.2.57) 통과. 추가된 bash 는 `case`·`local`·함수 호출뿐. wrapper.py 변경은 argparse `store_true` 와 print.
|
||||
|
||||
## 5. 테스트 실행 (검증 경로 …/2, 워크트리 .venv Python 3.9.6, `env -i` 로 HERDR_*·MAM_*·MQTT_*·WORKSPACE_ROOT 제거)
|
||||
| 실행 | 결과 |
|
||||
|---|---|
|
||||
| test_issue45_restore, test_issue45_restore_unit, test_issue17_item4_decisions, test_issue33_budget, test_issue5_phase1_delegation, test_tier1_unit | **461 passed / 0 failed / 0 skipped** (537 s; 실서버 restore 5건 포함, herdr 0.9.3) |
|
||||
| 새 테스트 19건, 1회차 head 코드 사본 | **15 failed** / 4 passed (실패 = yes/no 실패 9 + `--print` 단위 5 + 정적 가드; 통과 4 = 분기 불변 테스트, 의도대로) |
|
||||
격리: 실행 전후 herdr 프로세스 차이는 오케스트레이터의 `agent wait` pid 교체뿐, `/private/tmp/m45*` 없음, `~/.config/herdr` sessions·config.toml 불변, 실제 .mam/jobs 항목 수 불변.
|
||||
|
||||
## 6. 비차단
|
||||
- 1회차 N2(`_env` 가 상속 WORKSPACE_ROOT 를 비우지 않아 `_run_both` 4곳이 에이전트 pane 의 워크스페이스로 lib.sh 를 초기화할 수 있음)와 N3(herdr_restore.py docstring 줄바꿈)는 이 델타에서 다루지 않았다. 둘 다 이 PR 이 만든 문제가 아니며 그대로 비차단.
|
||||
- 새 관찰 없음.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Issue #60 리뷰 3차 — opencode 2.x 전용 지원 (job f1132474)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T073639Z-b5cb7f/1`
|
||||
- identity: HEAD `0e05b1b6777ceb837066454ac688e04a0f5d14a0`, tree `c5cb1b16af283b3fdb447bafb865ba1abbfbb5a8` — 일치, 작업 트리 clean, `.mam` 없음. base..head 5커밋(a311c23, f0d1c00, 6b4b96f, cb5f439, 0e05b1b). 판정은 base..head 전체에 대해 내린다.
|
||||
- 1차(6b4b96f)·2차(cb5f439)에서 실바이너리로 확인한 사항은 0e05b1b 가 건드리지 않은 제품 코드라 그대로 유효하다. 0e05b1b 는 status.sh, tests/_agent_home_guard.py, tests/test_issue60_opencode_v2.py 만 바꿨다. 확인한 사항:
|
||||
- 측정 사실과 구현의 일치
|
||||
- `session delete` 의 자식 삭제, cwd 무관, 공유 행 보존
|
||||
- 3단 자손 정리
|
||||
- 서비스 보유자 제외
|
||||
- `--standalone` 수명
|
||||
- resident children
|
||||
- 실제 opencode 데이터는 읽기(`grep -c`, `stat`)로만 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
2차 차단 지적(B1)과 비차단 N1·N2 가 모두 해결됐고 새 회귀는 찾지 못했다. base..head 전체를 병합 가능으로 판단한다. 남은 것은 테스트 보강 권고뿐이다.
|
||||
|
||||
## 2차 지적별 확인
|
||||
|
||||
**B1 (XDG_DATA_HOME 테스트 격리) — 해결.**
|
||||
- `_agent_home_guard.DROPPED_VARS` 에 `XDG_DATA_HOME` 이 추가됐다(`tests/_agent_home_guard.py:21`). 그래서 `conftest._isolate_agent_homes`(`conftest.py:210`)가 테스트마다 이를 지운다. mam_sandbox·issue31 `home` 처럼 HOME 만 바꾸는 fixture 도 이제 `<HOME>/.local/share` 를 본다.
|
||||
- 2차에서 실패한 두 테스트(tier2 opencode e2e, `test_opencode_purge_leaves_no_rows`)가 `XDG_DATA_HOME=<scratchpad>` 에서 통과한다(아래 실행 결과).
|
||||
- 뮤테이션: `DROPPED_VARS` 에서 다시 빼면 그 두 테스트와 `test_issue55::test_l1_every_path_variable_the_code_reads_is_accounted_for` 가 실패한다(3 failed). L1 은 환경과 무관하게 실패하므로, 이후 누가 지워도 잡힌다.
|
||||
- 세션 가드는 `XDG_DATA_HOME` 이 설정돼 있으면 `<XDG_DATA_HOME>/opencode` 를 감시한다(`:48-49`). `ambient` 는 conftest 가 drop 전에 기록한 pane 값(`_AGENT_HOME_VARS = ("HOME",) + DROPPED_VARS`)이라 올바른 위치다.
|
||||
|
||||
**N1 (status.sh 고정 경로) — 해결.**
|
||||
- 두 Python 블록(`status.sh:105-113`, `:305-313`) 모두 `get_adapter('opencode')._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))` 를 쓴다.
|
||||
- `cwd`(`:59`/`:259`)와 `home`(`HOME_DIR`, `:41`/`:241`)은 각 블록에 정의돼 있다. `env_python` 이 `PYTHONPATH=$SKILL_DIR` 를 넣고 환경을 상속하므로(`lib.sh:1852-1871`) `lib_py` import 와 PATH·XDG 가 그대로 전달된다. 같은 파일이 이미 `lib_py.delegation.logroot` 등을 import 한다.
|
||||
- 행동 확인: git archive 사본에 임시 테스트를 넣고 실행했다(mam_sandbox+mock_herdr+mock_agents → create → reconcile 로 id 고정 → status). 1 passed.
|
||||
- `--json` 의 `resume_state == "yes"` 와 plain 출력 행의 `yes`, 둘 다 rc 0.
|
||||
- PATH 에서 opencode 를 빼도 폴백 경로로 `yes`.
|
||||
- DB 를 `XDG_DATA_HOME` 쪽으로 옮기면, XDG 없이 `MISSING`, XDG 를 주면 `yes`.
|
||||
- 행을 지우면 `MISSING`. 모두 rc 0.
|
||||
- status 는 이제 opencode 행마다 `opencode debug paths` 를 실행한다. 이 명령은 opencode 자신의 log 디렉터리에 기록한다. 하지만 status 가 이미 돌리는 `reconcile.sh --dry-run` 의 drift C 도 같은 `_resolve_db` 를 부르므로 새로운 종류의 부작용은 아니다.
|
||||
|
||||
**N2 (`--version` 탐침이 실제 로그에 기록) — 해결.**
|
||||
- `_real_opencode()`(`test_issue60_opencode_v2.py:290-303`)가 `TemporaryDirectory` 를 HOME·TMPDIR 로 하고 PATH 만 넘긴다. XDG_* 는 넘기지 않는다.
|
||||
- 실제 `~/.local/share/opencode/log/opencode.log` 의 `args="[\"--version\"]"` 줄 수를 읽기로 세었다. 아래 두 스위트 실행 전 50, 후 50(증가 없음) 이다.
|
||||
|
||||
## 테스트 실행 (검증 경로, `env -i`, basetemp 는 scratchpad)
|
||||
|
||||
관련 10개 모듈: a4, issue31, issue10_13, issue60, tier2, issue55, issue57(status), issue21_worktree_prereqs(status), issue30_stall_watch, tier1.
|
||||
|
||||
- `XDG_DATA_HOME=<scratchpad>/jf1/xdg`, PATH 에 opencode 없음: **363 passed, 1 skipped**, rc 0. 실행 뒤 그 XDG 디렉터리는 비어 있었다(아무 파일·디렉터리도 생기지 않음).
|
||||
- `XDG_DATA_HOME` 미설정, PATH 에 `~/.opencode/bin` 포함(실바이너리 계약 테스트 실행): **363 passed, 1 skipped**, rc 0.
|
||||
- skip 은 두 번 모두 `/proc` 전용 테스트(macOS)다. #55 가드 오탐은 없었고, 실행 뒤 남은 opencode 프로세스도 없었다.
|
||||
|
||||
## 비차단 (테스트 보강 권고)
|
||||
|
||||
- **T1.** 이번에 바뀐 두 동작은 회귀 테스트가 없다. 뮤테이션(`XDG_DATA_HOME` 설정 상태, issue55·issue60·issue31·tier2):
|
||||
- 가드의 XDG 감시를 원래대로 되돌림 → 28 passed, 실패 0
|
||||
- status.sh 를 고정 경로로 되돌림 → 28 passed, 실패 0
|
||||
|
||||
`test_issue55::test_g1` 에 `watch_dirs({"HOME": h, "XDG_DATA_HOME": x})["opencode"] == x/opencode` 한 줄을 넣고, 위 임시 테스트와 같은 status 행동 테스트(XDG 쪽 DB → `yes`)를 넣기를 권한다.
|
||||
- **T2 (기존 설계).** 가드는 opencode 데이터 디렉터리의 항목 이름을 비교한다. `XDG_DATA_HOME` 이 설정된 Linux 러너에서 사용자 opencode(서비스 포함)가 그 디렉터리에 새 항목을 만들면 #55 와 같은 종류의 오탐이 날 수 있다. 판정 근거로 쓰지 말고 보고만 할 종류다.
|
||||
- 1·2차 비차단 N3(다른 MAM opencode 세션이 떠 있으면 무관한 purge 도 exit 4, lsof 없는 Linux 에서는 DB 보유자 검사 생략)는 의도된 동작으로 보고 그대로 둔다.
|
||||
|
||||
## 병합 가능성
|
||||
|
||||
- base..head 전체에서 `git diff --check` 는 깨끗하다.
|
||||
- 변경 Python 파일은 Python 3.9(`/usr/bin/python3` 3.9.6)로 컴파일된다. 셸 스크립트는 `/bin/bash -n`(3.2)을 통과한다.
|
||||
- 1·2차의 실바이너리 검증과 뮤테이션 결과에 이번 검증이 더해져, 차단 사항이 없다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,189 @@
|
||||
# 🔍 Review Report — PR #29 델타 리뷰 2회차 (job `f1174fc0`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-20
|
||||
- **대상 델타**: `46ea4cc..08db1da` (3커밋, `git diff --stat` = **16 files, +159/−34**), base `main 1134d74` (v4.6.0).
|
||||
- **검토 트리**: 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29` (detached @ `08db1da`).
|
||||
- **1회차 내 판정**: PASS (job `291e38b5`). 이번에는 **델타가 그 판정을 뒤집는지**를 본다.
|
||||
- **안전 규칙 준수**: 워크트리 읽기 전용(수정·커밋·checkout 없음, `git status --short` 클린 유지). 본 체크아웃 `main @ 1134d74` 그대로. `worktree_prepare.sh`·`worktree_remove.sh` 를 실제 워크트리·본 체크아웃에 실행하지 않았다 — 모든 실행은 scratchpad 의 임시 복사본/임시 저장소(uuid4 이름)에서만 했다. 실제 `.mam`·데몬·세션 조작 없음, push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**R1·R2·R3 은 실제로 닫혔다.** 특히 R1/R2 는 (a) 결함을 임시 저장소에서 직접 재현했고, (b) 워크트리 복사본에서 수정만 되돌렸을 때 새 회귀 4건이 정확히 실패하는 것까지 확인했다. 1회차 PASS 를 뒤집을 사유는 없다. 비차단 4건(D1–D4)만 남긴다. 그중 D1 은 이번 델타가 새로 만든 작은 구멍이다.
|
||||
|
||||
## 1. R1/R2 — `--untracked-files=all` (항목 1)
|
||||
|
||||
### 1-1. 결함 재현 (임시 저장소)
|
||||
|
||||
```
|
||||
git config status.showUntrackedFiles no ; echo secret > untracked.txt
|
||||
git status --porcelain --ignore-submodules=none → (빈 출력) ← 옛 검사: "클린"
|
||||
git status --porcelain --untracked-files=all --ignore-submodules=none → ?? untracked.txt ← 수정본
|
||||
```
|
||||
|
||||
표시 설정 하나로 "강제 제거해도 되는가"라는 판정이 뒤집혔다는 codex 의 지적은 사실이다.
|
||||
|
||||
### 1-2. 세 검사 모두에 들어갔는가 — 그렇다
|
||||
|
||||
| 파일:라인 | 검사 |
|
||||
|---|---|
|
||||
| `.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh:93` | `judge_tree()` 의 워크트리 청결 검사 |
|
||||
| `.agents/skills/lib_py/worktree.py:170` | `submodule_findings()` 의 `dirty` 검사(`modules/` 아래 모든 저장소) |
|
||||
| `.agents/skills/lib_py/loop/runner.py:718` | `_dirty_submodules()` (리뷰 스냅샷 승인 전 서브모듈 검사) |
|
||||
|
||||
스킬 트리 전체에서 `status --porcelain` 을 부르는 나머지 두 곳도 확인했다.
|
||||
- `worktree_prepare.sh:209`: 롤백 후 "이 워크트리가 아직 쓸 수 있는 체크아웃인가"만 보는 용도(stdout 은 버리고 stderr 만 본다). 파괴 판정이 아니다 — 플래그 불필요.
|
||||
- `runner.py:581`: 프롬프트의 `미커밋 변경: 있음/없음` **표시용**. 판정에 쓰이지 않는다(→ D3 참조).
|
||||
|
||||
### 1-3. 소스 트리 스냅샷은 별도 조치가 필요 없는가 — 필요 없다
|
||||
|
||||
`_worktree_tree()` 의 `git add -A` 는 `status.showUntrackedFiles` 를 **보지 않는다**. 임시 저장소에서 `showUntrackedFiles=no` 로 설정한 뒤 `read-tree HEAD` → `add -A` → `write-tree` 한 트리에 `untracked.txt` 가 그대로 들어갔다. 즉 스냅샷은 이미 untracked 를 고정하고 있었고, 구멍은 "서브모듈 검사"와 "제거 판정"에만 있었다. 수정 범위가 정확하다.
|
||||
|
||||
`_change_set_state()`(`runner.py`)와 `diff_collect.sh` 의 untracked 수집은 `git ls-files -o --exclude-standard` 라 이 설정의 영향을 받지 않는다. 빈 변경분 fail-closed 가드도 안전하다.
|
||||
|
||||
### 1-4. 새 회귀가 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
워크트리를 `.git`·`.venv` 제외하고 scratchpad 에 복사한 뒤, **세 곳의 `--untracked-files=all` 만 제거**하고 돌렸다(워크트리 자체는 건드리지 않았다).
|
||||
|
||||
```
|
||||
4 failed, 5 passed in 14.69s
|
||||
FAILED test_issue21_phase2_loop_a.py::test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal
|
||||
FAILED test_issue21_phase2_loop_a.py::test_w6e_work_written_while_stopping_is_not_force_removed[True]
|
||||
FAILED test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale[untracked-hidden]
|
||||
FAILED test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale[during-hidden]
|
||||
```
|
||||
|
||||
기존 변형(`[False]`, `before`, `before-ignored`, `untracked`, `during`)은 그대로 통과했다. 즉 새 회귀는 **정확히 이 수정만** 검증하며, 회귀 방지력이 실재한다. `test_w6e2` 가 단언 안에서 `assert _git(wt,"status","--porcelain","--ignore-submodules=none").stdout == "", "git hides it by default"` 로 전제 자체를 고정한 것도 좋다.
|
||||
|
||||
## 2. R3 — 워크트리 자체 venv (항목 2)
|
||||
|
||||
`worktree_prepare.sh:111-134` 의 분기를 읽고 문서·테스트와 대조했다(스크립트는 실제 트리에 실행하지 않았다).
|
||||
|
||||
| 요구 | 구현 | 판정 |
|
||||
|---|---|---|
|
||||
| 심링크가 아닌 실제 venv | `"$MAIN/.venv/bin/python" -m venv "$WT/.venv"` + `requirements.txt`·`requirements-dev.txt` 설치 | **충족**. 1회차 내 지적 N2 가 닫혔다 |
|
||||
| 멱등성 | `[ -x "$WT/.venv/bin/python" ]` → `VENV_STATE="kept"` | 충족(`test_w1` 이 `VENV=created` → `VENV=kept` 순서를 고정) |
|
||||
| 남은 링크 거부 | `[ -L "$WT/.venv" ]` → exit 1, **아무것도 바꾸지 않음** | 충족(`test_w1b` 가 링크 보존까지 단언) |
|
||||
| 인터프리터 부재 | `WARNING: ... not found; no venv created` + `VENV=skipped` | 충족 |
|
||||
| 설치 실패 시 exit 1, 워크트리 유지 | `pip install` 실패 → 메시지 `(the worktree is kept)` + exit 1. `git worktree add` 는 이미 끝난 뒤라 실제로 남는다 | 충족 |
|
||||
| 문서 정합 | `create/SKILL.md:246`, `stop/SKILL.md:123`, 설계 문서 4장이 모두 "자체 venv·링크 거부·`VENV=` 요약"으로 갱신됨 | 충족 |
|
||||
| ignore 규칙 정합 | `.gitignore` 관리 블록의 `/.venv`(#27) 와 공유 `info/exclude` 의 `/.venv` 는 **디렉터리에도 그대로 적용**된다. `test_w1` 이 준비 후 `status --porcelain` 이 비는지 단언 | 충족 |
|
||||
|
||||
`bash -n` 은 변경된 두 스크립트 모두 통과한다. `req=()` 배열 + `[ -f ... ] && req+=(...)` 가 `set -euo pipefail` 아래서 조기 종료하지 않는지 macOS `/bin/bash` 3.2.57 로 직접 확인했다(두 파일 모두 없을 때도 루프 끝까지 도달, rc 0).
|
||||
|
||||
### D1 (비차단, 이번 델타가 새로 만든 구멍) — 설치 실패 뒤 재실행이 조용히 `VENV=kept`
|
||||
|
||||
`pip install` 이 실패하면 exit 1 이지만 `$WT/.venv/bin/python` 은 **이미 만들어져 있다**(`:121` 에서 venv 생성이 먼저 끝난다). 그래서 사용자가 그대로 재실행하면 `:117-118` 의 `-x` 검사에 걸려 `VENV=kept` 로 넘어가고, **요구사항이 설치되지 않은 환경**을 정상으로 보고한다. 메시지는 "fix it and re-run" 이라 재실행을 유도한다.
|
||||
|
||||
→ 권고: 설치 성공을 표시하는 마커(예: `$WT/.venv/.mam-requirements-ok`)를 두고 `kept` 판정에 함께 쓰거나, `kept` 경로에서도 `requirements*.txt` 설치를 한 번 더 시도.
|
||||
|
||||
### D2 (비차단, 정보) — 준비 비용·네트워크 의존
|
||||
|
||||
이제 워크트리마다 venv 생성 + PyPI 설치가 필요하다. 오프라인이거나 PyPI 가 막힌 호스트에서는 prepare 가 exit 1 로 끝난다(이전에는 링크 한 번이라 즉시 끝났다). SKILL.md 에 설치 동작은 적혀 있으나 "네트워크가 필요하다"는 명시는 없다.
|
||||
|
||||
### D3 (비차단, 사소) — 프롬프트의 `미커밋 변경` 표시는 여전히 설정에 취약
|
||||
|
||||
`runner.py:581` 의 `git status --porcelain` 에는 플래그가 없어, `status.showUntrackedFiles=no` 인 소스에서는 untracked 만 있을 때 `미커밋 변경: 없음` 으로 표시된다. 판정·스냅샷·서브모듈 검사는 모두 안전하므로 **표시 오차**일 뿐이지만, R1 의 취지("표시 설정이 판단 재료를 바꾸면 안 된다")를 생각하면 같은 플래그를 주는 편이 일관적이다.
|
||||
|
||||
## 3. #28 이식성 수정(`7a94ba2`)이 의도를 약화시키는가 (항목 3)
|
||||
|
||||
### 3-1. `ps -eo pid=,ppid=,args=` — 양쪽에서 같은 출력인가
|
||||
|
||||
이 호스트(macOS 15, arm64)에서 **두 형태를 같은 프로세스에 대해 직접 비교**했다(파이프로 캡처, 코드와 동일 조건).
|
||||
|
||||
```
|
||||
['-axo','pid=,ppid=,command='] → 길이 575, 인자 X 400개 그대로
|
||||
['-eo','pid=,ppid=,args='] → 길이 575, 인자 X 400개 그대로
|
||||
```
|
||||
|
||||
- **구분자·필드**: 동일(`pid ppid args`), 잘림 없음. 400자 argv 가 온전히 나왔다 — 파이프 출력일 때는 폭 제한이 걸리지 않는다.
|
||||
- **이식성**: `-e`(모든 프로세스), `-o`(포맷), `args`(전체 명령행)는 POSIX 다. 반대로 `-a`+`-x` 조합과 `command=` 는 BSD 관용이고 procps 에서는 경고·다른 해석이 나올 수 있다. purge 12건의 원인 가설로 타당하며, **macOS 동작은 그대로**다(위 실측).
|
||||
- 남는 차이: Linux 의 `args` 도 커널 `/proc/<pid>/cmdline` 기준이라 매우 긴 argv 는 환경에 따라 잘릴 수 있다. 다만 이 코드의 판정(에이전트 CLI 여부, UUID 포함 여부)은 앞쪽 토큰에 의존하므로 실질 영향은 없다.
|
||||
|
||||
### 3-2. `_normalize_help`
|
||||
|
||||
`optional arguments:` → `options:` 치환 + 공백 런 축약이다. 즉 **레이아웃(줄바꿈·들여쓰기) 동등성은 포기하고 내용 동등성만** 본다. 골든 캡처가 3.9 에서 떠졌고 대상 호스트가 3.14 라는 사정상 타당하며, 플래그·인자·설명 문구가 바뀌면 여전히 실패한다(시험의 목적은 "shim 이 구현과 CLI 상 구분 불가"이므로 유지). 약화는 제한적이고 대안(버전별 골든 캡처)은 유지비가 크다 — 수용.
|
||||
|
||||
### 3-3. `test_workspace_scope.py` 의 `bash -c`
|
||||
|
||||
`shell=True` 는 `/bin/sh` 를 쓰므로 dash 에서 `source` 가 없다. 문자열을 그대로 `["bash","-c", cmd]` 로 옮긴 것은 최소 변경이며 검증 내용(=`resolve_herdr_session` 의 출력)은 그대로다. 수용.
|
||||
|
||||
### 3-4. conftest 의 `MAM_ENV_FILE` 고정
|
||||
|
||||
`tests/conftest.py:173-177` 이 autouse 픽스처에서 `MAM_ENV_FILE` 을 `tmp_path/.mam-test-state/.mam.env.absent` 로 고정한다. 방향은 #20·#15 와 같은 **격리 강화**이고, 실제 `.mam.env` 를 읽어야 하는 시험은 스스로 `MAM_ENV_FILE` 을 설정한다(`test_issue14/18/5_phase1/21_worktree_prereqs/20_state_isolation` 모두 자체 설정을 갖고 있다). "브로커 없음" 계열 시험이 호스트의 실 설정을 주워 읽던 것이 오히려 결함이었으므로 계약을 약화시키지 않는다.
|
||||
|
||||
## 4. `90a289d` 벽시계 여유 8 → 15 s (항목 4)
|
||||
|
||||
이 시험이 고정하는 결함은 "idle grace 가 wall clock 을 **무시**해 `2 × TIMEOUT`(≈40 s) 에 닫히는 것"이다. 임계는 `20 + 15 = 35 s` 이므로 결함 시나리오(40 s)는 여전히 실패한다. 정상 종료는 내가 1회차 전체 스위트에서 실측한 값이 28.49 s·28.50 s(단독 재실행 포함 2회 일치)였다.
|
||||
|
||||
```
|
||||
정상 28~29 s < 새 임계 35 s < 결함 40 s
|
||||
```
|
||||
|
||||
여유는 5 s 로 좁아졌지만 계약(“wall clock 을 넘기지 않는다”)의 판별력은 남는다. 1회차에서 내가 유일한 비-flake 실패로 보고했던 항목이 이 커밋으로 해소된다. 다만 `timeout_s` 가 더 커지면 `2 × TIMEOUT` 과 `TIMEOUT + 15` 의 간격은 벌어지고 작아지면 좁아지므로, **고정 여유(+15)보다 비율(예: `timeout_s * 1.5`)** 이 계약을 더 정확히 표현한다(D4, 비차단 권고).
|
||||
|
||||
## 5. 테스트 실행 (워크트리, 이 리뷰어가 직접)
|
||||
|
||||
```
|
||||
cd <pr-29 워크트리> && PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider -q -rfEs \
|
||||
tests/test_issue21_phase2_loop_a.py tests/test_issue21_loop_b.py tests/test_issue21_worktree_prereqs.py \
|
||||
tests/test_issue21_acceptance.py tests/test_workspace_scope.py tests/test_issue5_phase1_delegation.py \
|
||||
tests/test_deploy_freshness.py tests/test_version_consistency.py
|
||||
→ 208 passed in 322.94s (failed 0 · errors 0 · skipped 0)
|
||||
```
|
||||
|
||||
- 브리프가 지적한 **`tests/test_issue28_*.py` 는 이 트리에 없다**(#28 수정은 기존 파일에 들어갔다) — 규칙대로 건너뛰었다.
|
||||
- **skip 0 건**: `test_deploy_freshness.py` 의 새 `skipif`(D-11~D-19, D-22~D-30)는 이 호스트에서 **전부 실행**됐다(서브모듈 자산이 있으므로 의도대로다). 브리프 항목 3 의 우려("로컬 자산이 있는 호스트에서는 실행되어야 한다")는 실측으로 해소.
|
||||
- 가장 느린 시험 10건은 모두 herdr mock·정지/재개 경로이며 브로커 타임아웃 계열 실패는 없었다.
|
||||
|
||||
### 격리·위생 실측
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs` 전후 | **차이 0** |
|
||||
| 워크트리 `git status --short --ignored` 전후 | **완전 동일** |
|
||||
| 워크트리 HEAD / 본 체크아웃 | `08db1da` / `main 1134d74` (둘 다 변화 없음) |
|
||||
| 샌드박스 실험 | scratchpad 임시 디렉터리에서만(복사본 리버트 실행 포함). 실제 스크립트·워크트리에 prepare/remove 실행 없음 |
|
||||
|
||||
## 6. 병합 차단 여부 (항목 5)
|
||||
|
||||
버전 락스텝 bump 부재(`lib.sh:32`·`eager.py:16` 모두 `4.6.0`, 오케스트레이터가 4.7.0 으로 처리 예정) 외에 **병합을 막을 결함은 없다**. 1회차 PASS 판정은 이번 델타로 뒤집히지 않으며, 1회차에 내가 남긴 비차단 지적 중 **N2(.venv 심링크)는 이 델타로 해소**됐다. 1회차의 N1(스냅샷 커밋이 ref 없이 남아 gc 로 소멸)은 그대로 남아 있으니 후속 이슈로 유지하기를 권한다.
|
||||
|
||||
| # | 이번 회차 지적 | 성격 |
|
||||
|---|---|---|
|
||||
| D1 | pip 설치 실패 후 재실행이 `VENV=kept` 로 조용히 통과 | 후속 권고(이번 델타가 만든 것) |
|
||||
| D2 | prepare 가 워크트리마다 venv 생성 + PyPI 설치를 요구(오프라인 exit 1) | 정보·문서화 |
|
||||
| D3 | `runner.py:581` 의 표시용 status 에는 `--untracked-files=all` 이 없음 | 사소(일관성) |
|
||||
| D4 | 벽시계 여유를 고정값(+15 s)이 아니라 비율로 두는 편이 계약에 충실 | 사소 |
|
||||
|
||||
## 7. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
WT=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-29
|
||||
# R1 결함 재현 (임시 저장소에서만)
|
||||
R=$(mktemp -d); cd "$R"; git init -q .; git config user.email t@t; git config user.name t
|
||||
echo a>a; git add a; git commit -qm base; git config status.showUntrackedFiles no; echo secret > untracked.txt
|
||||
git status --porcelain --ignore-submodules=none # (빈 출력)
|
||||
git status --porcelain --untracked-files=all --ignore-submodules=none # ?? untracked.txt
|
||||
GIT_INDEX_FILE=$R/idx git read-tree HEAD; GIT_INDEX_FILE=$R/idx git add -A
|
||||
git ls-tree -r --name-only "$(GIT_INDEX_FILE=$R/idx git write-tree)" # untracked.txt 가 들어 있다(= add -A 는 설정 무시)
|
||||
# 수정 없이는 회귀가 실패하는지 (워크트리 복사본에서만)
|
||||
S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam "$WT/" "$S/"
|
||||
sed -i '' 's/ --untracked-files=all --ignore-submodules=none/ --ignore-submodules=none/' \
|
||||
"$S/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" "$S/.agents/skills/lib_py/loop/runner.py"
|
||||
sed -i '' 's/"git status --porcelain --untracked-files=all"/"git status --porcelain"/' "$S/.agents/skills/lib_py/worktree.py"
|
||||
(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$WT/.venv/bin/python" -m pytest -p no:cacheprovider -q \
|
||||
tests/test_issue21_phase2_loop_a.py::test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal \
|
||||
tests/test_issue21_phase2_loop_a.py::test_w6e_work_written_while_stopping_is_not_force_removed \
|
||||
tests/test_issue21_loop_b.py::test_l17_dirty_submodule_is_refused_or_makes_the_round_stale) # 4 failed, 5 passed
|
||||
# ps 형태 비교(잘림·구분자)
|
||||
python3 - <<'PY'
|
||||
import subprocess,sys,time
|
||||
p=subprocess.Popen([sys.executable,"-c","import time;time.sleep(6)","X"*400]); time.sleep(1)
|
||||
for f in (["-axo","pid=,ppid=,command="],["-eo","pid=,ppid=,args="]):
|
||||
l=[x for x in subprocess.run(["ps",*f],capture_output=True,text=True).stdout.splitlines() if x.split()[:1]==[str(p.pid)]]
|
||||
print(f, len(l[0]), l[0].count("X"))
|
||||
p.wait()
|
||||
PY
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261026T133440Z-c8961b",
|
||||
"base_sha": "29cc4fd90478936f15387a7665e2c95d13a3f14c",
|
||||
"head_sha": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"tree_sha": "98d57bdc60920238c115ece90c90cc694f2744bc",
|
||||
"review_commit": "5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "f39ae4ba",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-26T17:32:11Z"
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
# PR #73 (Issue #71) 리뷰 — head 5f60653
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01 (macOS Darwin 25.6, `/bin/bash` 3.2.57, `/usr/bin/python3` 3.9.6, BSD sed/tr)
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261026T133440Z-c8961b/2`
|
||||
- identity 확인 (시작·종료 시 모두):
|
||||
- HEAD 5f6065344f800c2c5ad6f802e95e9e8ff5cc7b26, tree 98d57bdc60920238c115ece90c90cc694f2744bc
|
||||
- `git status --porcelain --ignored` 비어 있음
|
||||
- `.mam` 없음. 추적 파일 `.mam.env.example` 만 있다.
|
||||
- main HEAD 29cc4fd 그대로.
|
||||
- 범위: 29cc4fd..5f60653, 커밋 14개, 파일 28개 (+953/−176).
|
||||
- 읽은 것: 이슈 #71 본문과 댓글("범위 정리" 1–10항, #72 분리), PR #73 본문.
|
||||
|
||||
## 0. 이번 잡(f39ae4ba)에 대해
|
||||
|
||||
- 이 잡은 같은 루프의 검증 경로 `/2` 다. head 5f60653, tree 98d57bdc 는 앞 잡 0698ffc5(검증 경로 `/1`) 와 같다. 브리프의 확인 항목도 잡 ID와 경로를 빼면 글자까지 같다.
|
||||
- 그래서 아래 1장의 분석(파일:줄 근거, 리뷰어 시험 p1–p12, 변이 m1–m12, base 비교)은 같은 tree 의 `git archive` 사본에서 얻은 결과를 그대로 쓴다.
|
||||
- 이번 경로 `/2` 에서 새로 확인한 것:
|
||||
- 브리프에 붙은 diff(1920줄)가 `git diff 29cc4fd 5f60653` 와 바이트까지 같다. 커밋은 14개다.
|
||||
- `/2` 의 `lib.sh` 에서 `_sanitize_herdr_agent_name` 을 다시 뽑았다. 정의는 하나이고, 앞서 비교한 정의와 같다.
|
||||
- `/2` 의 `lib_py` 를 기준으로 bash↔Python 비교를 다시 돌렸다. 408건에 불일치는 0이다. python3 와 해시 도구가 모두 없는 5건도 기대대로 동작했다.
|
||||
- 테스트 세 묶음을 `/2` 에서 다시 돌렸다. 결과는 아래 2장 표와 같다.
|
||||
- issue71+issue66: 77 passed
|
||||
- A: 384 passed, 1 skipped
|
||||
- B: 543 passed, 3 skipped
|
||||
- 실제 herdr 서버 두 개(pid 11655, 69309)는 실행 전후로 같다.
|
||||
- 시작과 끝에 `/2` 의 HEAD·tree 를 확인했다. `status --ignored` 는 비어 있고 `.mam` 은 없다. main HEAD 는 29cc4fd 그대로다.
|
||||
|
||||
## 1. 확인한 것과 근거
|
||||
|
||||
### 1) 완료 조건과 범위
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 1 attach 형태 | 충족 | 아래 3) |
|
||||
| 2 `default` 도 적음 | 충족 | 아래 3) |
|
||||
| 3 래퍼 attach | 충족 | 아래 3) |
|
||||
| 4 두 필드 삭제 | 충족 | 아래 4) |
|
||||
| 5 원칙(스킬로 시작·종료), 행 없는 stop | 충족 | 아래 4), 5) |
|
||||
| 6 attach 는 예외 | 충족 | attach 만 herdr 명령을 직접 쓴다 |
|
||||
| 7 변환 규칙 하나 | 충족 | 아래 6) |
|
||||
| 8 해시 불가 시 오류 | 충족 | 아래 6) |
|
||||
| 9 마이그레이션 | 충족 | 아래 7) |
|
||||
| 10 stall_watch 한 곳 | 충족 | 아래 8) |
|
||||
|
||||
- 보강 완료 조건도 모두 충족한다.
|
||||
- 새 행에 두 필드가 없다.
|
||||
- 해시 도구가 없으면 python3 로 같은 결과를 내고, python3 도 없으면 오류다.
|
||||
- 옛 행의 마이그레이션은 멱등이고, YAML 과 DB 가 같다.
|
||||
- 행 없는 stop 은 레지스트리를 바꾸지 않는다.
|
||||
- 정리 안내는 stop 스킬 명령이다.
|
||||
- #72(update 스킬)는 끌어들이지 않았다.
|
||||
- 진입점 `python3 -m lib_py.registry_format` 만 두었다.
|
||||
- 그 진입점을 부르는 코드는 없다.
|
||||
- 본문 완료 조건 6(실제 에이전트를 32자 초과 이름으로 띄워 attach)은 수동 확인 항목이다. 브리프 제약(실제 에이전트·실제 herdr 금지)에 따라 하지 않았다.
|
||||
- 대신 형태가 맞는지는 확인했다. 실제 herdr 0.9.5 의 `agent attach --help` 를 격리 HOME 에서 보면 사용법이 `herdr agent attach <TARGET> [OPTIONS]` 이고, 옵션은 `--takeover` 다.
|
||||
|
||||
### 2) 회귀 테스트 tests/test_issue71_herdr_names.py (70개)
|
||||
|
||||
- head (검증 경로): issue71 70개와 issue66 7개를 함께 돌려 77 passed.
|
||||
- base: 29cc4fd 사본에 head 의 테스트 파일을 넣었다. conftest 는 이 PR 에서 바뀌지 않았다.
|
||||
- 결과: **68 failed, 2 passed**. PR 이 말한 숫자와 같다.
|
||||
- 통과하는 2개:
|
||||
- `test_the_names_need_converting_and_the_plain_one_does_not`: 변환 전제를 확인한다.
|
||||
- `test_wrapper_attach_calls_herdr_with_the_herdr_name[PLAIN]`: 변환이 필요 없는 이름이라 base 에서도 맞다.
|
||||
- reconcile 경로 (`test_reconcile_registers_with_the_attach_command`, test :126-139) 는 행 등록 경로에 실제로 닿는다.
|
||||
- mock herdr 에 `cwd=mam_sandbox`, `pane_id` 가 있는 agent 를 넣는다.
|
||||
- 실행 전에 행이 없음을 확인한다 (:132).
|
||||
- 실행 뒤 `last_visible_note == "auto-registered by monitor"` 인 행이 하나인지 확인한다 (:137-138).
|
||||
- 변이 m10 (reconcile 을 옛 형태로) 이 이 테스트 3개를 실패시킨다.
|
||||
|
||||
### 3) attach_command (`lib_py/agents/sanitize.py:34-43`)
|
||||
|
||||
- 형태: `herdr --session <shlex.quote(server or "default")> agent attach <shlex.quote(sanitize(name))>`.
|
||||
- 서버가 None 이나 빈 값이어도 `--session default` 가 된다.
|
||||
- 변환된 이름은 `[a-z0-9_-]` 이므로 quote 를 해도 그대로다.
|
||||
- 서버 `srv x` 는 `'srv x'` 로 감싸진다. 내 시험과 test :51 에서 확인했다.
|
||||
- 쓰는 곳은 네 곳이고, 모두 이 함수를 쓴다:
|
||||
- create: `create_session.sh:566`
|
||||
- resume 의 새 행: `update_yaml_resumed.sh:151`
|
||||
- resume 의 서버 변경: `update_yaml_resumed.sh:161`
|
||||
- reconcile: `reconcile.sh:676`
|
||||
- 마이그레이션도 같은 함수를 쓴다 (`registry_format.py:62`).
|
||||
- resume 의 else 분기는 서버가 바뀔 때만 attach_command 를 다시 쓴다 (:159-161).
|
||||
- 서버가 그대로인 기존 행은 첫 쓰기의 마이그레이션이 이미 새 형태로 바꿔 둔다. 그래서 낡은 값이 남지 않는다.
|
||||
- lib.sh 래퍼 (`lib.sh:605-613`):
|
||||
- `agent attach <t> ...` 의 첫 인자를 `_resolve_herdr_target` 으로 변환한다. get/read/prompt 와 같은 함수다.
|
||||
- 나머지 `"$@"` 는 그대로 넘긴다.
|
||||
- 테스트 :151-159 가 strict mock 에서 `["agent","attach",<herdr 이름>,"--takeover"]` 를 확인한다.
|
||||
- 변이 m4 (원래 이름을 넘김) 는 LONG·UPPER 에서 잡힌다.
|
||||
|
||||
### 4) kill_command·start_command 삭제
|
||||
|
||||
- 코드·문서에는 이 두 키가 남아 있지 않다. 리뷰 트리 전체를 grep 했다.
|
||||
- 남은 곳: 마이그레이션 상수 (`registry_format.py:37`), 테스트의 "옛 행" 픽스처와 부재 단언, 과거 리뷰 보관본 `.agents/reports/*`.
|
||||
- `status --json` 의 `start_command` 키는 빠졌다 (`status.sh:224-228`).
|
||||
- 이 키를 읽는 코드나 테스트는 없다.
|
||||
- status SKILL.md 의 예시도 같이 고쳤다.
|
||||
- 래퍼의 `new-session`/`kill-session` 가짜 명령은 남겨 두는 것이 맞다. 아직 쓰는 곳:
|
||||
- create (`create_session.sh:266-276`, :346)
|
||||
- stop (`stop_session.sh:157`, :307, :321)
|
||||
- reconcile
|
||||
- 사용자에게 보이던 kill-session 안내 두 곳은 stop 스킬 명령으로 바뀌었다:
|
||||
- create exit 43 (`create_session.sh:364`)
|
||||
- id_order (`id_order.py:343-345`)
|
||||
- 바뀐 명령 `stop_session.sh --session X --herdr-session S` 는 행이 없어도 동작한다 (아래 5).
|
||||
- exit 43 은 `_mark_row_rolled_back "running" "rollback-failed"` 로 행을 남기므로, 그 경우는 행 있는 stop 경로를 탄다.
|
||||
|
||||
### 5) 행이 없는 stop (`stop_session.sh:117-191`)
|
||||
|
||||
- **행이 없을 때만 그 경로로 간다.**
|
||||
- 행을 찾는 Python 은 행이 없을 때만 `sys.exit(7)` 을 낸다.
|
||||
- `[ "${MAPPED_RC:-0}" -ne 7 ] || stop_rowless` (:189) 로 7 일 때만 row-less 로 간다.
|
||||
- 다른 실패는 `cannot read the row` 로 exit 1 이다 (:190-193).
|
||||
- 변이 m6 (0 이 아니면 모두 row-less) 은 `test_stop_does_not_take_an_unreadable_row_for_no_row` 에 잡힌다.
|
||||
- **레지스트리를 읽지 못할 때**는 `load_state_json strict` (:170, lib.sh:1598-1631) 가 exit 1 로 끝난다.
|
||||
- 해당 경우: YAML 깨짐(DB 없음), DB 손상, sessions 테이블 없음, 테이블 전부 없음.
|
||||
- 테스트 :514-541 의 4개 매개변수가 이를 확인한다.
|
||||
- 인자 없는 기존 호출은 동작이 같다. strict 일 때만 raise 하거나 exit 한다.
|
||||
- **작업 공간 판정** (:142-151) 은 `realpath` 두 개를 비교한다: `cwd == root or cwd.startswith(root + os.sep)`. 내 추가 시험 (head 사본, `tests/test_zz_review71.py`) 결과:
|
||||
|
||||
| 경우 | 결과 |
|
||||
|---|---|
|
||||
| p1 접두어 혼동 (`<ws>-other`) | exit 5, 닫지 않음 |
|
||||
| p2 밖에서 작업 공간 안으로 가는 심볼릭 링크 | 0, 닫음 |
|
||||
| p3 작업 공간 안에서 밖으로 가는 심볼릭 링크 | exit 5 |
|
||||
| p4 후행 슬래시 | 0 |
|
||||
| p5 WORKSPACE_ROOT 를 심볼릭 링크로 지정 | 0 |
|
||||
| p7 32자 초과 이름 (strict mock) | 0 |
|
||||
|
||||
- **서버 결정 순서** (:107-113, :128-130) 는 `--herdr-session` → 환경의 `HERDR_SESSION_NAME` → 작업 공간 서버다.
|
||||
- 환경 값이 비었거나 `default` 이면 작업 공간 서버로 간다. create 의 :185, :253 과 같은 규칙이다.
|
||||
- p6 (환경 값 `default`) 에서 모든 `agent get` 이 작업 공간 서버로 간 것을 확인했다.
|
||||
- 테스트 :487-499 는 환경 서버를 확인한다.
|
||||
- **레지스트리를 바꾸지 않는다.** 모든 row-less 테스트가 YAML/DB 해시를 비교한다 (`_registry_bytes`).
|
||||
- **`--purge-conversation`** 은 행이 없으면 exit 1 이다 (:124-126, test :544-550).
|
||||
- **종료 코드:**
|
||||
- 0 / 1 / 5 / 6 은 SKILL.md 표 (:58-67) 와 헤더 주석 (:30-36), 코드 (:134, :154, :161, :163) 가 같다.
|
||||
- `test_literal_exit_rule_on_base_scripts` 는 `{0..6}` 이다.
|
||||
- skill_code_blocks 의 `entry_points` 도 5·6 을 더했다.
|
||||
|
||||
### 6) 이름 변환 (`lib.sh:44-82`)
|
||||
|
||||
- 정의는 하나다 (`lib.sh:44`). test :171 이 확인한다.
|
||||
- shim 은 `{ printf shebang; declare -f _sanitize_herdr_agent_name; cat <<'EOF' … } > "$tmp_file"` (:179-1393) 으로 같은 정의를 받는다.
|
||||
- bash 3.2 의 `declare -f` 출력을 다시 source 한 사본도 아래 비교에 넣었다.
|
||||
- 이 호스트의 `bash` 는 `/bin/bash` 3.2 하나뿐이라, 테스트의 shim 도 3.2 로 돈다.
|
||||
- has-session 의 `lambda s: s.lower()[:32]` 대체와 `os.path.abspath(".agents/skills")` import 는 없어졌다.
|
||||
- 대신 lib.sh 가 계산한 `TARGET_SAN` 을 넘긴다 (:633-641).
|
||||
- **macOS 실측 비교:** 이름 34개를 Python `sanitize_herdr_agent_name` 과 비교했다.
|
||||
- 이름: ASCII, 대문자, 32/33/40/200자, 숫자·`_`·`-n`·`-e`·`--help` 로 시작, `\n`(가운데·앞·뒤·연속), `\t`, `\r`, `*?[a]`, `\`, `%s`, `Ü`, `İstanbul…`, `日本語`, 이모지, `K`(U+212A), `ß`, `Dž`, `Ω…` 긴 이름, NBSP 등.
|
||||
- 원본 정의와 `declare -f` 사본 2 벌 × 환경 6 개 (`LANG=en_US.UTF-8`, `LC_ALL=C`, 로캘 없음, `LC_ALL=UTF-8`+`LC_CTYPE=C`, 해시 도구 없이 python3 만 + UTF-8 또는 C) = **408 건, 불일치 0**.
|
||||
- ASCII 가 아닌 이름은 바이트 단위 치환을 쓰지 않고 python3 의 문자 단위 `str.lower()`+치환을 쓰므로 Python 과 같다 (:51-56).
|
||||
- **해시 도구별:** `/sbin/sha1sum` 만, openssl(LibreSSL) 만, 깨진 shasum(빈 출력)에서 openssl 로 넘어가는 경우 모두 Python 과 같았다 (`mam-wt-issue-71-very-lo-54e536a7`).
|
||||
- **python3 도 해시 도구도 없을 때:**
|
||||
- 짧은 ASCII 이름(대문자, 끝의 `\n` 포함)은 정상이다.
|
||||
- 32자 초과는 `no shasum, sha1sum, openssl or python3` 오류로 rc 1 이다.
|
||||
- ASCII 가 아닌 이름은 `not ASCII and there is no python3` 오류로 rc 1 이다.
|
||||
- `00000000` 은 어디에도 없다.
|
||||
- 호출하는 곳은 실패를 넘긴다: `|| return 1`, `|| exit 1`, `wait_for_agent_idle` 은 `|| return 3`, delegate-job 은 진단만 건너뛴다.
|
||||
- spawn 의 `agent_name` 계산은 루프 앞 (:688) 으로 옮겨졌다. 그래서 이름을 못 구하면 spawn 하지 않는다. test :267 이 확인한다.
|
||||
|
||||
### 7) 레지스트리 형식 2 (`registry_format.py`, `atomic_yaml.py:118-137, :192`)
|
||||
|
||||
- **순서:** 잠금 → `BEGIN IMMEDIATE` (:79-87) → DB 또는 YAML 읽기 → 마이그레이션 (:121-126) → 호출자 변경 (:144-149) → 검증 → DB 쓰기 → commit → YAML 쓰기.
|
||||
- 마이그레이션은 잠금 안에서, 호출자 변경 전에 돈다.
|
||||
- **모든 행을 바꾼다.** `migrate` 는 이름이 있는 모든 행에서 두 키를 지우고 attach_command 를 다시 만든다.
|
||||
- stopped·terminated 도 포함한다. 변이 m8 (running 만) 은 6 failed 로 잡힌다.
|
||||
- **YAML 도 반드시 다시 쓴다.** `force_yaml = True` (:126) 이고, 변이 m2 는 6 failed 로 잡힌다.
|
||||
- **멱등이다.** test :327-338 이 확인하고, 진입점을 두 번 부르면 `2 -> 2, rows changed: 0` 에 바이트도 같다 (test :342-362).
|
||||
- **YAML 교체가 실패한 뒤의 복구:** DB 가 2 이고 YAML 이 2 미만이면 다음 쓰기에서 `force_yaml` 이 된다 (:127-137).
|
||||
- 진입점으로 하든 아무 쓰기로 하든 복구된다 (test :379-411).
|
||||
- 변이 m3 은 2 failed 로 잡힌다.
|
||||
- **다른 쓰기 경로:** 레지스트리 YAML/DB 를 쓰는 코드는 `atomic_yaml` 하나뿐이다.
|
||||
- grep 결과 `safe_dump`, `INTO state/sessions` 는 이 파일에만 있다. hermes.py 의 DELETE 는 에이전트 자신의 DB 다.
|
||||
- orc_onboard 와 autopin 도 `atomic_dump_yaml(_main)` 을 거친다.
|
||||
- 호출자 변경 가운데 `d` 를 통째로 새로 만드는 것은 없다. 만들어도 다음 쓰기가 다시 마이그레이션하므로 형식 키를 잃지 않는다.
|
||||
- **읽기 경로:** `load_state_json` 과 `lib_py.state` 는 바꾸지 않았다. strict 인자만 더했다.
|
||||
- **미래 형식 (p8):** `registry_format: 3` 이면 진입점은 `3 -> 3, rows changed: 0` 이다. 쓰기 뒤에도 키는 3 으로 남고, 행(옛 키 포함)도 그대로다. 내려 쓰지 않는다.
|
||||
- **비정상 값 (p9):** `"2"`, `True`, `None` 은 1 로 보고 2 로 바꾼다. `0` 은 `0 -> 2` 로 출력된다.
|
||||
- **진입점 계약:**
|
||||
- 성공은 stdout 한 줄 `registry-format: <from> -> <to>, rows changed: <n>` 에 exit 0 이다.
|
||||
- 레지스트리가 없으면 exit 1 이다 (test :423).
|
||||
- DB 손상 (p10) 은 stderr `registry-format: error: file is not a database` 에 exit 1 이고 stdout 은 비어 있다.
|
||||
- 검증 거부 (p11) 는 `registry-format: error: VALIDATE: … bad status` 에 exit 1 이다.
|
||||
- 모두 docstring 과 같다.
|
||||
- **macOS persistent WAL:** YAML/DB 바이트 비교 테스트(진입점 재실행, row-less stop)가 이 호스트에서 모두 통과했다.
|
||||
|
||||
### 8) stall_watch
|
||||
|
||||
- `StallWatch.__init__` 이 `self.herdr_name` 을 한 번 구한다 (:229).
|
||||
- 그 값을 `HerdrSource(..., name=self.herdr_name)` (:230) 와 `wait_once` (:332) 에 같이 쓴다.
|
||||
- `HerdrSource` 는 keyword-only `name` 이 필수다. 원래 이름으로 대신 쓰는 경로는 없다.
|
||||
- 다른 호출자는 `test_issue60_linux_fixes.py:99` 하나이고, `name="t"` 를 넘기도록 바뀌었다.
|
||||
- `tests/test_issue66_stall_watch_sanitized_name.py` 는 diff 가 없고, 7 passed 다.
|
||||
|
||||
### 9) 바뀐 기존 테스트
|
||||
|
||||
- `test_tier2_component`: `HERDR_SESSION_NAME=` 포함 여부 대신 새 attach 형태 전체가 같은지와 두 키가 없는지를 본다.
|
||||
- 서버 전파와 라벨이 새지 않는지를 보는 원래 의도는 유지되고, 단언은 더 엄격해졌다.
|
||||
- `test_issue50_pane_lock:173`: create 가 쓴 행이라는 증거를 `start_command` 대신 `session_id_source` 로 본다.
|
||||
- reconcile 의 행 (`reconcile.sh:666-680`) 에는 이 키가 없다. 그래서 증거로서 같은 구분을 한다.
|
||||
- `test_issue21_worktree_prereqs:584`: 제어 루트 경로가 새지 않는지를 attach_command 에서 본다. 의도가 같다.
|
||||
- `test_tier1_unit` / `test_tier3_integration`:
|
||||
- "agent 를 추론할 수 없으면 exit 2" 를 이제 **행이 있는** 세션으로 확인한다. 행이 없으면 row-less 경로로 가기 때문이다.
|
||||
- 의도가 유지된다.
|
||||
- `test_issue63`, `test_issue23_24` S9: 안내 문구를 새 명령으로 바꿨다.
|
||||
- `test_layout`, `test_issue42`: heredoc 시작 위치와 종료 코드 집합을 따라 고쳤다.
|
||||
- `tests/data/skill_code_blocks.json`:
|
||||
- 블록 33개 가운데 sha 가 바뀐 것은 문서 내용이 바뀐 두 블록이다: create :134 (YAML 행 예시), status :74 (JSON 예시).
|
||||
- 나머지는 줄 이동과 evidence 줄 번호다.
|
||||
- `entry_points` 의 stop 종료 코드에 5·6 을 더했다.
|
||||
- 문서 diff 와 맞다.
|
||||
- **S11** (`test_issue23_24_startup_path.py:294-316`):
|
||||
- 비교 기준을 `0fc162e` 로 두었다. `0fc162e..HEAD` 사이 lib.sh 변경은 5f60653 의 `load_state_json` 하나뿐이고, shim heredoc 과 sanitize 함수는 바뀌지 않았다.
|
||||
- merge commit 으로 병합하면 0fc162e 가 main 이력에 남으므로 계속 맞다.
|
||||
- 기준을 찾지 못하면 `pytest.skip` 한다. 이는 base(525df2b) 때부터의 동작이다. git 이 없는 설치본이나 squash 병합에서 건너뛰는 것은 기존 설계 그대로라고 본다.
|
||||
- 검증 경로(git worktree)에서는 **skip 이 아니라 통과**했다 (`-k s11`: 1 passed).
|
||||
- squash 하면 이 확인이 사라지므로 PR 본문의 "merge commit 필수" 를 지켜야 한다.
|
||||
|
||||
### 10) macOS 호환
|
||||
|
||||
- 바뀐 Python 6개 파일이 `/usr/bin/python3` 3.9.6 에서 compile 된다.
|
||||
- `attach_command` 와 `migrate` 가 3.9 에서 실행된다.
|
||||
- 바뀐 셸 줄에 `declare -A`, `mapfile`, `${x,,}`, `sed -i`, `stat -c`, `readlink -f`, `local -n` 이 없다.
|
||||
- 이름 변환은 BSD tr/sed 와 bash 3.2 에서 위 6) 처럼 확인했다.
|
||||
|
||||
## 2. 실행한 테스트
|
||||
|
||||
- 실행 조건:
|
||||
- 모두 `env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`, 임시 HOME/XDG_DATA_HOME 에서 돌렸다.
|
||||
- `PYTHONDONTWRITEBYTECODE=1 -p no:cacheprovider --basetemp=<스크래치>` 를 썼다.
|
||||
- `.mam.env` 는 export 하지 않았다.
|
||||
- 실제 herdr 서버 두 개는 테스트 전후로 같다: pid 11655 `herdr server`, 69309 `herdr --session multi-agent-mux server`, 시작 시각도 같다.
|
||||
|
||||
| 대상 | 결과 |
|
||||
|---|---|
|
||||
| 검증 경로: test_issue71_herdr_names + test_issue66_stall_watch_sanitized_name | **77 passed** (`/1`, `/2` 모두) |
|
||||
| base 29cc4fd 사본 + head 의 issue71 테스트 | **68 failed, 2 passed** |
|
||||
| 검증 경로 A: tier1_unit, tier2_component, tier3_integration, issue23_24_startup_path, issue50_pane_lock, issue21_worktree_prereqs, issue63_same_cwd_ids, issue60_linux_fixes, layout, issue42_skill_blocks | **384 passed, 1 skipped** (`/1`, `/2` 모두; skip: issue42 의 `.claude/skills is untracked; absent in a worktree`, 환경 요인) |
|
||||
| 검증 경로 B (stop·reconcile·create·resume 인접): b4_session_created, b19_headless_reconcile_fixes, c1_tui_readiness, issue15_registry_isolation, issue17_workspace_root_isolation, issue31_purge_residual, issue24_resume_dialogs, issue40_auto_pin, issue45_restore, issue6_fail_closed, issue9_server_env, issue64_ro_connect, uuid_target, workspace_scope, issue57_logs_follow_registry, issue30_stall_watch, issue30_stall_watch_stop, issue50_dispatch_herdr, issue60_opencode_v2, issue36_prompt_transport, a4_adapter_contract, issue26_ints | **543 passed, 3 skipped** (`/1`, `/2` 모두) |
|
||||
| S11 단독 (검증 경로) | 1 passed (skip 아님) |
|
||||
| 리뷰어 추가 시험 (head 사본, p1–p12) | 14 passed. 1개는 내 기대값 오류다 (형식 0 → "0 -> 2" 출력, 동작은 정상) |
|
||||
| bash↔Python 이름 변환 비교 | 408 건 불일치 0, 도구 없음 5 건 모두 기대대로 (`/2` 의 lib.sh·lib_py 로 다시 실행) |
|
||||
|
||||
변이 (head 사본, issue71 70개):
|
||||
|
||||
| # | 변이 | 결과 |
|
||||
|---|---|---|
|
||||
| m1 | 작업 공간 판정을 `startswith(root)` 로 (접두어 혼동) | **살아남음** (리뷰어 p1 은 잡음) |
|
||||
| m2 | 마이그레이션 뒤 YAML 강제 쓰기 끔 | 6 failed |
|
||||
| m3 | YAML 복구 분기 끔 | 2 failed |
|
||||
| m4 | 래퍼 attach 에 원래 이름 | 2 failed |
|
||||
| m5 | 줄바꿈 → `-` 제거 | 6 failed |
|
||||
| m6 | rc≠0 이면 모두 row-less | 1 failed |
|
||||
| m7 | strict 에서 state 테이블 오류만 무시 | **살아남음** (아래 N3) |
|
||||
| m8 | running 행만 마이그레이션 | 6 failed |
|
||||
| m9 | 서버 없으면 `--session ''` | 3 failed |
|
||||
| m10 | reconcile 을 옛 attach 형태로 | 3 failed |
|
||||
| m11 | 행 없는 stop 이 환경 서버를 무시 | 1 failed |
|
||||
| m12 | 해시 실패 시 `00000000` | 4 failed |
|
||||
|
||||
## 3. 차단 결함
|
||||
|
||||
없음.
|
||||
|
||||
## 4. 비차단
|
||||
|
||||
### N1: 행 없는 stop 이 제어 루트 밖 워크트리의 세션은 닫지 못한다
|
||||
|
||||
- 상황:
|
||||
- `worktree_prepare.sh:229-236` 은 creator 를 `WORKSPACE_ROOT=$MAIN` 과 `--workspace $WT` 로 만든다.
|
||||
- `$WT` 는 `--path` 로 받은 임의의 경로이고, 보통 제어 루트 밖이다. 루프의 `mam-wt-*` 나 `$TMPDIR/mam-review/…` 가 그 예다.
|
||||
- 결과:
|
||||
- 그런 create 가 행을 등록하기 전에 죽으면, id_order 의 안내 (`id_order.py:343-345`) 는 stop 명령을 준다.
|
||||
- 그 stop 은 `realpath(cwd)` 가 `WORKSPACE_ROOT` 밖이므로 exit 5 로 거부한다 (`stop_session.sh:151-154`). 다른 작업 공간 테스트 (test :467) 와 같은 경로다.
|
||||
- 거부는 안전하다(아무것도 바꾸지 않음). 하지만 이 경우에는 안내된 스킬 명령으로 정리할 수 없다.
|
||||
- 권고: 둘 중 하나.
|
||||
- (a) row-less 판정에 "이 제어 루트에 묶인 워크트리(`git worktree list`)" 도 넣는다.
|
||||
- (b) exit 5 메시지에 그 경우의 정리 방법을 적는다.
|
||||
|
||||
### N2: 접두어 혼동을 고정하는 테스트가 없다
|
||||
|
||||
- m1 이 PR 테스트에서 살아남는다. `<ws>-other` 를 cwd 로 하는 한 줄짜리 시험을 더하기를 권한다. 내 p1 이 그 형태다.
|
||||
|
||||
### N3: strict 읽기에서 state 테이블만 없는 DB 는 걸리지 않는다
|
||||
|
||||
- m7 이 살아남은 이유는 테스트의 `no-tables` 가 sessions 테이블 오류로도 잡히기 때문이다.
|
||||
- state 만 없는 DB 에서도 stop 은 sessions 테이블로 행을 정확히 찾으므로 영향은 없다.
|
||||
|
||||
### N4: 이 PR 이 건드리지 않은 attach 안내가 남아 있다 (모두 base 부터 있던 것)
|
||||
|
||||
- `create_session.sh:770`: `Attach: herdr session attach $SESSION_NAME`. 서버를 적지 않고, 이름도 변환하지 않는다. 행의 `attach_command` 를 출력하기를 권한다.
|
||||
- `multi-agent-mux-delegate-job:1021`: `HERDR_SESSION_NAME=… herdr agent attach $sess (lib.sh를 source한 셸에서)`. 래퍼가 이제 이름을 변환하므로 동작은 하지만, 새 형태와 다르다.
|
||||
- `deploy/install_mam.sh:330`: `HERDR_SERVER_NAME=… herdr agent attach`. 없는 환경 변수 이름이다.
|
||||
|
||||
### N5: 래퍼 attach 는 대상이 첫 인자라고 가정한다
|
||||
|
||||
- `herdr agent attach --takeover <name>` 순서로 쓰면 래퍼는 `--takeover` 를 대상으로 찾다가 exit 1 을 낸다.
|
||||
- herdr 의 사용법(`<TARGET> [OPTIONS]`)과 문서의 순서대로라면 문제가 없다.
|
||||
|
||||
### N6: YAML 이 아직 없는 작업 공간에서는 행 없는 stop 이 동작하지 않는다
|
||||
|
||||
- 처음 만든 작업 공간에서 첫 create 가 행 등록 전에 죽어 YAML 이 아직 없으면, stop 은 행 없는 경로 전에 `YAML not found` 로 exit 1 한다 (:101).
|
||||
- 드문 경우다.
|
||||
|
||||
### N7: 정리할 만한 작은 것들
|
||||
|
||||
- `create_session.sh:519` 의 `server_opt` 는 쓰는 곳이 없다. base 에서도 그랬다.
|
||||
- shim 내용이 바뀌었으므로, 같은 제어 루트를 쓰는 옛 버전 체크아웃과 새 버전 체크아웃이 섞이면 `.mam/shim/herdr` 를 서로 덮어쓴다. #45 때와 같은 전환기 비용이다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Issue #60 리뷰 — opencode 2.x 전용 지원 (job f4cedbae)
|
||||
|
||||
- 리뷰어: planner-reviewer-claude-01
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261018T055915Z-abe16e/1`
|
||||
- identity 확인: HEAD `6b4b96fa1875862ef3bed830fe7b04166b1fad58`, tree `9cfb2106081eba96b58cc6b7573031de0acba399` — [리뷰 identity] 와 일치, 작업 트리 clean, `.mam` 없음. base..head 커밋 3개(a311c23, f0d1c00, 6b4b96f).
|
||||
- 실제 opencode 데이터·서비스는 건드리지 않았다. 실제 경로는 `ls`·`lsof -t`·`ps` 로만 봤다. 실제 바이너리(v2.0.32)는 전부 `HOME=<scratchpad>/j60/h*`, `TMPDIR=<scratchpad>/j60/t*`, `XDG_*` unset, `--standalone`(또는 `debug paths`·`--help`)로만 실행했고, 모델 호출 프롬프트는 보내지 않았다. 각 실험 뒤 `ps` 로 남은 opencode 프로세스가 없음을 확인했다(사용자 서비스 pid 86524 `opencode serve --service` 제외).
|
||||
|
||||
## 결론 요약
|
||||
|
||||
구현은 측정 사실과 맞고, 실제 2.0.32 바이너리로 직접 재현한 결과도 일치한다. 차단 결함은 찾지 못했다. 테스트는 회귀를 잘 잡는다(뮤테이션 13개 중 12개 검출). 아래 비차단 항목 중 N1(opt-in 실바이너리 테스트의 PATH 의존)은 Linux 호환성 시험 전에 고치기를 권한다.
|
||||
|
||||
## 테스트 실행 (검증 경로, `env -i` + PATH 에 `~/.opencode/bin` 포함, basetemp 는 scratchpad)
|
||||
|
||||
- 관련 11개 모듈(`test_a4_adapter_contract`, `test_issue31_purge_residual`, `test_issue10_13_purge`, `test_issue60_opencode_v2`, `test_tier1_unit`, `test_tier2_component`, `test_issue5_phase0_contracts`, `test_issue23_24_startup_path`, `test_issue6_fail_closed`, `test_c1_tui_readiness`, `test_issue9_server_env`): **420 passed in 723.50s**, rc 0 (opt-in 실바이너리 계약 테스트 포함; #55 가드 오탐도 이번에는 없음). 실행 뒤 남은 opencode 프로세스 없음.
|
||||
- `test_issue60_opencode_v2.py` 를 PATH 에 opencode 없이 실행: 1 failed, 9 passed — 아래 N1.
|
||||
|
||||
## 1. 측정 사실 ↔ 구현 대조 (실제 opencode 2.0.32, 임시 HOME 에서 재현)
|
||||
|
||||
| 항목 | 직접 측정 | 구현 |
|
||||
|---|---|---|
|
||||
| `debug paths` | exit 0, 0.04 s, `db <HOME>/.local/share/opencode/opencode.db` 줄 포함(공백 정렬). DB 는 만들지 않음(`log/`·`repos/` 디렉터리만 생성) | `opencode.py:93-98` 이 `split(None, 1)` 로 `db` 줄을 읽고 `strip()` — 경로의 공백도 보존됨. 실패·타임아웃(5 s) 시 기본 경로 폴백(`:101`) |
|
||||
| TUI 플래그 | `--help` 에 `--standalone`·`--auto`·`--session, -s` 있음, `--agent`·`-m` 없음. `--session` 은 "continue, or to create if it does not exist" | `spawn_spec`/`resume_spec`(`:198-204`), create/resume/update_yaml 의 셸 사본 모두 `--auto --standalone` |
|
||||
| 세션 저장소 | 새 2.x DB 에 `session`·`message`·`part` 테이블 없음, 세션은 `session_v2`(`parent_id`, `directory`=realpath, `time_created` ms) | verify/discover/_has_session/status/reconcile 모두 `session_v2` |
|
||||
| `session delete --standalone -- <id>` | 부모 P 삭제 시 자식 C 의 `session_v2`·`session_message`·`event_sequence` 행까지 삭제, 같은 디렉터리의 다른 세션 O 는 그대로. 프로젝트의 `event_sequence`(aggregate_id=project id) 같은 공유 행은 남음. **cwd 와 무관**(다른 프로젝트 cwd 에서 지워도 동작). 없는 id → stderr `Session not found: <id>`, exit 1 | `purge_artifacts`(`:136-150`) 가 `session_v2` 에 있을 때만 CLI 호출, `--` 사용, 실패 시 `WARN` + `residual` 에 `session_v2.id` 보고(`:192-193`). 가짜 CLI(`tests/_fake_opencode.py`)의 출력 스트림·exit 코드·자식 재귀 삭제가 실제와 같음 |
|
||||
| `--standalone` 수명 | pty 안에서 TUI(`opencode --auto --standalone`)가 자식 `opencode serve --stdio --port 0` 을 띄움. 시작만으로는 `session_v2` 에 행이 생기지 않음(첫 메시지 전). `/exit`(입력 후 1 s 뒤 Enter) → 종료, pty 닫힘 → 0.11 s 안에 종료, TUI SIGKILL → 서버 자식도 2 s 안에 종료. 모든 경우 남은 프로세스 0 | stop 의 graceful(`/exit` via send_keys_safe)→kill-session→SIGKILL 체인은 변경 없음이며 어느 단계에서도 프로세스가 남지 않는다 |
|
||||
|
||||
참고: 브리프의 "TUI 는 SIGTERM·SIGHUP 을 무시" 는 내 pty 실험에서는 재현되지 않았다(SIGTERM 한 번에 TUI·서버 모두 종료). 어느 쪽이든 stop 체인에는 유리한 방향이라 판정에 영향 없음.
|
||||
|
||||
## 2. 코드 리뷰
|
||||
|
||||
- `opencode.py:62-87` `_bin`/`_run`: 기존 `_resolve_db` 안의 탐색 로직을 그대로 뽑아낸 것. `_run` 은 HOME·XDG_DATA_HOME 을 ctx.home_dir 기준으로 덮어써 `debug paths` 와 `session delete` 가 같은 DB 를 보게 한다(일관). `cwd` 가 없으면 None — `session delete` 가 cwd 무관함을 실측했으므로 문제 없음.
|
||||
- `verify_artifact`(`:107-131`): `session_v2` 의 `directory, time_created` 고정 스키마. ms→s 변환, epoch 비교, workspace_key 비교 유지.
|
||||
- `discover`(`:213-239`): `parent_id IS NULL` — 실제 스키마에서 subagent 세션은 `parent_id` 가 채워지고 `directory` 가 같으며 더 늦게 생성됨을 확인(C 가 P 보다 161 ms 뒤). 필터가 없으면 reconcile 이 두 후보로 `C-ambiguous` 가 되거나 자식을 고를 수 있으므로 정당하다.
|
||||
- `purge_artifacts`: `_has_session` 이 거짓이면(1.x 에만 있는 세션, 없는 id) CLI 를 부르지 않는다. 1.x 테이블 SQL 삭제는 `session_id`/`id` 정확 일치만이라 다른 세션·공유 행을 지우지 않는다. 1.x 테이블이 없으면 "sqlite rows" 항목을 보고하지 않도록 바뀐 것(`:172-173`)도 맞다.
|
||||
- reconcile drift C(`reconcile.sh:952-994`): `PRAGMA table_info(session_v2)` + `parent_id IS NULL`(컬럼 있을 때만). `_resolve_db` 가 이제 항상 경로를 돌려주므로 `:916-930` 의 파일시스템 폴백은 사실상 죽은 코드(기존 코드, 비차단).
|
||||
- status.sh 두 곳 `session_v2`. Python 3.9(`/usr/bin/python3` 3.9.6, venv 3.9.6) 파싱 OK, 셸 5개 `/bin/bash -n`(3.2) OK, `git diff --check` 깨끗.
|
||||
- 남은 1.x 가정 검색(`agent build`, `db path`, `FROM session\b`, `table_info(session)`, `opencode -m`; VERSIONS.md·bug-report·.agents/reports 제외): 코드·문서에 남은 곳 없음(주석·테스트 docstring 의 설명적 언급만).
|
||||
|
||||
## 3. 테스트가 회귀를 잡는가 — 뮤테이션 (git archive 사본, `-k "opencode and not real_opencode"`, 3개 모듈)
|
||||
|
||||
| 뮤테이션 | 결과 |
|
||||
|---|---|
|
||||
| discover 를 `session` 테이블로 | 5 실패 |
|
||||
| discover 의 `parent_id IS NULL` 제거 | 1 실패(`test_discover_and_verify…skip_child_sessions`) |
|
||||
| verify_artifact 를 `session` 테이블로 | 6 실패 |
|
||||
| purge 에서 CLI 호출 제거 | 4 실패 |
|
||||
| `--` 제거 / `--standalone` 제거 | 각 1 실패 |
|
||||
| `_resolve_db` 를 `db path` 로 | 2 실패 |
|
||||
| residual 에서 `session_v2.id` 제거 | 3 실패 |
|
||||
| reconcile `session` 테이블로 / status `session` 으로 | 각 1 실패(구조 검사) |
|
||||
| create_session.sh·spawn_spec 을 `--agent build` 로 | 각 1 실패 |
|
||||
| **reconcile drift C 의 `parent_id IS NULL` 제거** | **0 실패** (N2) |
|
||||
|
||||
## 4. 비차단 지적
|
||||
|
||||
**N1. opt-in 실바이너리 테스트가 opencode 가 PATH 에 없으면 거짓 실패한다** (`tests/test_issue60_opencode_v2.py:182-228`).
|
||||
`_real_opencode()` 는 import 시점(실제 HOME)에 `~/.opencode/bin/opencode` 폴백으로 바이너리를 찾아 테스트를 켠다. 그런데 테스트 안에서 `HOME` 을 임시 디렉터리로 바꾼 뒤 어댑터의 `_bin()` 은 `shutil.which` → `ctx.home_dir/.opencode/bin`(임시 home) → `expanduser("~")`(역시 임시 home) 순이라 바이너리를 못 찾는다. 결과:
|
||||
```
|
||||
env -i PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin ... pytest tests/test_issue60_opencode_v2.py
|
||||
FAILED test_real_opencode_2x_contract - assert [] == ['opencode session (with its child sessions): ses_…']
|
||||
WARN: purge opencode: no opencode binary to delete session ses_…
|
||||
1 failed, 9 passed
|
||||
```
|
||||
PATH 에 `~/.opencode/bin` 을 넣으면 10 passed. 공식 설치기의 기본 위치가 `~/.opencode/bin` 이고 PATH 등록은 셸 rc 에 의존하므로, Linux 호환성 시험(CI·비로그인 셸)에서 제품 문제로 오인될 수 있다. 같은 이유로 `_resolve_db` 단언(`:218`)은 폴백 경로와 같아서 `debug paths` 파싱을 검증하지 못한 채 통과한다. 수정 예: 테스트에서 `monkeypatch.setattr(adapter, "_bin", lambda ctx: REAL)` 또는 `monkeypatch.setenv("PATH", os.path.dirname(REAL) + os.pathsep + os.environ["PATH"])`. 브리프가 정한 실행 방식(`env -u …`, PATH 상속)에서는 통과하므로 차단으로 보지 않았다.
|
||||
|
||||
**N2. reconcile drift C 의 `parent_id` 필터는 행동 테스트가 없다.** `test_status_and_reconcile_read_session_v2` 는 문자열 검사뿐이고, tier2 e2e(`test_comp_opencode_create_and_reconcile_and_stop`)는 자식 세션을 시드하지 않는다. 위 표처럼 필터를 지워도 아무 테스트도 실패하지 않는다. 어댑터 discover 쪽은 잡힌다.
|
||||
|
||||
**N3. (기존 동작, 이번 PR 이 바꾸지 않음) opencode 의 in-use 판정은 공유 DB 를 lsof 하므로, 어떤 opencode 프로세스든 DB 를 열고 있으면 항상 "사용 중"이다.** `base.py:307-309` 가 `artifact_path`(=모든 세션이 공유하는 `opencode.db`)를 lsof 한다. 이 Mac 에서는 사용자 서비스(pid 86524 `opencode serve --service`)가 실제 DB 를 열고 있다(`lsof -t ~/.local/share/opencode/opencode.db` → 86524). 임시 HOME 재현: DB 를 아무 프로세스가 열지 않으면 `in-use opencode <id>` → `in_use: false`, rc 0, 다른 python 이 같은 DB 를 열어 두면 → `in_use: true`, rc 4. 따라서 이 Mac 에서는 `stop --purge-conversation` 이 opencode 세션에 대해 항상 exit 4 로 거부되어, 이번에 새로 만든 `session delete` 경로는 stop 을 통해서는 닿지 않는다(안전한 쪽의 오탐). 힌트 문구도 명령줄만 언급하고 DB 를 연 프로세스는 말하지 않는다. base 의 `_resolve_db` 도 같은 DB 로 귀결되므로 회귀는 아니다. 세션 단위 판정(예: `--session <id>` argv 와 그 세션의 TUI pid)으로 바꿀지는 별도 이슈로 다룰 일이다.
|
||||
|
||||
**N4. (기존 동작) XDG_DATA_HOME 덮어쓰기.** `_run` 은 사용자의 `XDG_DATA_HOME` 을 무시하고 `~/.local/share` 로 강제하며, status.sh·reconcile 폴백도 `~/.local/share/opencode` 고정이다. Linux 에서 `XDG_DATA_HOME` 을 따로 둔 사용자라면 pane 의 TUI 는 다른 DB 에 쓴다. 기존 코드와 같은 동작이며 기본 설정에서는 문제 없다. Linux 시험 때 확인할 항목으로 남긴다.
|
||||
|
||||
**N5. (사소) `_resolve_db` 가 호출마다 opencode 를 실행한다.** discover 는 후보마다 `verify_artifact` → `_resolve_db` 를 다시 부른다(최대 21회 × 약 0.04 s). 기존 `db path` 호출과 같은 구조라 회귀는 아니다.
|
||||
|
||||
## 5. Linux 이식성
|
||||
|
||||
- 경로: `debug paths` 가 HOME·XDG 를 따르고, 어댑터는 그 출력을 쓴다. `/proc`·macOS 전용 명령은 새로 쓰지 않았다. in-use 는 기존 `ps -eo` + (있으면) `lsof`.
|
||||
- 테스트: 가짜 CLI 는 `#!/usr/bin/env python3`, 래퍼는 `#!/bin/sh` + `exec` — Linux 에서도 동작. 실바이너리 테스트는 `os.path.realpath(ws)` 로 디렉터리를 맞춘다(macOS `/private` 접두사도 처리). N1 만 Linux 에서 문제가 될 수 있다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,84 @@
|
||||
# Issue #40 구현 계획 정교화 — agy 동일 cwd 오등록 방지
|
||||
|
||||
## 반영한 이의제기와 결론
|
||||
|
||||
Creator의 이의는 타당합니다. `last_conversations.json`은 cwd를 키로 UUID 하나만 저장하므로 그 값을 후보로 삼는 것만으로는 agy 후보가 최대 하나가 됩니다. 따라서 일반적인 `candidate_count > 1` 검사는 이 경우 작동하지 않습니다. 이 캐시는 대화를 연 시점에 갱신되며 현재 대화의 소유자를 증명하지도 않습니다. 특히 `agy.verify_artifact()`의 형제 UUID 검사가 `cache_match`가 아닐 때만 실행된다면, cache hit만 사용하는 자동 pin 경로에서 형제 배제는 무력화됩니다. 같은 cwd의 동시 세션은 실제 지원 시나리오이므로 epoch와 viewport 확인도 대화 소유권을 대신할 수 없습니다.
|
||||
|
||||
이에 따라 agy는 **캐시 일치만으로 자동 pin하지 않습니다.** 캐시는 검사할 UUID를 제안하는 데만 쓰고, 해당 UUID의 열린 presence lock을 보유한 프로세스가 현재 행의 pane 프로세스이거나 그 자손인지 확인해야 합니다. 확인할 수 없거나 소유자가 불일치하면 fail closed로 미확정 처리합니다. 같은 cwd에 다른 non-stopped agy 세션이 있으면 이 소유권 증명이 필수이며, 증명 없이 pin하지 않습니다. 형제 UUID 배제는 cache hit 여부와 무관하게 항상 적용합니다.
|
||||
|
||||
## 현재 기준선과 범위
|
||||
|
||||
대상 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/mam-wt-issue40`, 브랜치 `fix/issue-40-auto-pin`은 base/head 모두 `ff8b381520ae04e80c73a9e18bfad73d47e853c9`이며 변경 사항이 없습니다. 본 작업은 계획서 갱신이며 코드·브랜치를 수정하지 않습니다. 구현은 이 워크트리에서 진행합니다.
|
||||
|
||||
목표는 Class A 어댑터의 첫 로컬 `completed` 작업을 통해 미등록 세션 ID를 안전하게 확정하는 것입니다. 어댑터는 수명주기와 읽기 전용 capture 사실을 제공하고, completed를 검증한 로컬 수신 측만 YAML/SQLite를 갱신합니다. `publish_event.py`는 계속 이벤트 발행만 맡습니다. wrapper subscriber 종결 처리와 `loop.runner.wait_for_job()` 복구 경로는 같은 handler를 호출하고 `(job_id, dispatch_token, attempt_id)`로 중복을 제거합니다. remote/human job 및 pre-assigned ID는 제외합니다.
|
||||
|
||||
## 정교화한 설계
|
||||
|
||||
1. **Capture 결과와 lifecycle 계약**
|
||||
|
||||
`BaseAgentAdapter`에 `id_lifecycle` 및 `capture_active_session_id(ctx)` 계약을 추가합니다. 결과는 ID와 구체적 상태/사유를 포함해 `captured`, `no-candidate`, `ambiguous(shared-cwd)`, `unverifiable-owner`, `owner-mismatch`, `sibling-claimed`, `stale`, `wrong-workspace` 등을 구분합니다. Claude/Grok은 `pre_assigned`로 선언하고 자동 capture하지 않습니다. agy·opencode·codex·hermes는 `post_discovered` 대상입니다. 추가/기존 어댑터가 결과를 명확히 전달하도록 하며, `None` 하나로 모든 실패 원인을 뭉개지 않습니다.
|
||||
|
||||
2. **공용 handler와 저장 경계**
|
||||
|
||||
`lib_py/agents/autopin.py`와 같은 공용 경로에 판정·pin handler를 둡니다. 검증된 local `completed` job, 세션 row, transport, agent, dispatch token 및 attempt를 확인하고, 기존 ID/lifecycle을 존중한 뒤 adapter capture, workspace/epoch 검증, sibling 배제, viewport 확인을 수행합니다. 판정 코드는 저장소를 직접 우회하지 않고 기존 `atomic_dump_yaml` 경로에서 row와 관련 SQLite 상태를 갱신합니다. 성공 때 own ID, `session_id_source='discovered'`, `session_id_verified=True`, 표시 상태를 일관되게 기록하고, 실패 시 기존 own ID를 변경하지 않은 채 구체적 사유를 보존합니다. capture 실패는 이미 성공한 작업의 terminal 상태를 실패로 뒤집지 않습니다.
|
||||
|
||||
3. **agy는 캐시 후보와 실행 중 소유권을 모두 증명**
|
||||
|
||||
- `last_conversations.json[normalized_cwd]`는 후보 UUID 검색의 시작점일 뿐, 단독 pin 근거로 사용하지 않습니다. 해당 map이 다른 cwd를 가리키는 UUID는 즉시 거절합니다.
|
||||
- 후보 UUID의 `presence/<uuid>.lock` (필요하면 기존 artifact 경로 계약에 따른 DB/sidecar)을 열고 있는 PID를 `lsof`로 조회합니다. 소유 PID는 해당 행의 `pane.pid` 또는 그 자손 프로세스여야 합니다. PID/조상 관계를 확인하는 동안 프로세스 종료·PID 재사용 가능성을 고려해 실제 프로세스 존재와 계보 확인에 실패하면 성공으로 추정하지 않습니다.
|
||||
- lsof 부재/오류, lock 미점유, pane PID 누락, 계보 불일치 등의 경우 pin하지 않고 각각 재현 가능한 fail-closed 사유를 기록합니다. 실제 CLI 실행 상태를 확인할 수 없는 환경에서 cache-only fallback은 두지 않습니다.
|
||||
- 동일 normalized cwd를 가진 다른 non-stopped agy row가 있으면 owner 검사가 통과해야만 그 소유 행에 pin할 수 있습니다. 서로 다른 행이 같은 캐시 UUID를 갖는 동시 처리에서도 pane 소유권을 통과한 행만 후보가 됩니다. owner가 불확실하면 둘 다 미확정으로 남을 수 있으며 이는 안전한 결과입니다.
|
||||
- `verify_artifact()`의 sibling claim 검사는 cache match와 무관하게 먼저 적용합니다. 다른 활성/등록 행이 이미 보유한 UUID를 거절합니다. own row의 재검증은 자기 claim을 sibling으로 오인하지 않도록 제외합니다.
|
||||
- reconcile drift C와 completed capture는 같은 agy 판정기를 호출합니다. 대화 폴더 `*.db` 전체를 훑어 얻은 단독 후보를 자동 pin하지 않습니다. 다른 workspace의 DB를 읽지 않도록 막고, DB 조회는 `base._ro_connect`를 사용합니다.
|
||||
|
||||
4. **나머지 Class A 어댑터**
|
||||
|
||||
OpenCode는 세션 DB의 directory/time, Codex는 state DB의 cwd와 rollout metadata, Hermes는 sessions의 cwd/started_at을 사용합니다. 각 후보는 workspace와 세션 epoch(`herdr_session_epoch`) 조건을 모두 만족해야 하며 검증된 후보가 정확히 하나일 때만 확정합니다. 다수 후보는 `ambiguous`로 남깁니다. agy의 presence owner 규칙은 다른 어댑터에 억지로 일반화하지 않습니다.
|
||||
|
||||
5. **동시 completed 처리와 재수신**
|
||||
|
||||
job/token/attempt 키 아래 직렬화하고 처리 결과를 남깁니다. wrapper와 loop 복구 경로가 연속 또는 동시 호출되어도 하나의 capture만 수행합니다. handler가 상태 저장 전에 중단된 경우 재시도는 기존 own ID와 artifact를 재검증해 멱등이어야 합니다. agy의 owner 판별은 각 처리 시점에 수행하며, 캐시가 바뀌어도 UUID 소유 pane을 잘못 선택하지 않습니다.
|
||||
|
||||
6. **호출 지점**
|
||||
|
||||
subscriber가 검증한 completed, subscriber timeout 뒤 disk fallback에서 확정한 completed, loop `wait_for_job()`의 completed 반환 직전 모두 동일 handler로 이어집니다. 기존 wrapper가 순수 상태 매핑을 맡고 있다면 얇은 Python 호출 경계를 추가합니다. subscriber와 `publish_event.py`에 registry/YAML/SQLite 쓰기를 추가하지 않습니다.
|
||||
|
||||
## 단계별 실행 및 검증 기준
|
||||
|
||||
### 1단계 — 실패 재현과 회귀 테스트
|
||||
|
||||
- Creator의 같은 cwd 재현을 테스트 fixture로 옮깁니다. A와 B가 동일 cwd이고 cache는 UUID U 하나를 가리키며, U의 presence lock 소유자는 A의 pane PID인 상태에서 B는 discover/capture/verify 모두 U를 얻지 못해야 합니다.
|
||||
- 동일 cwd 동시 onboard 완료 두 건을 모의합니다. cache UUID는 하나여도 결과는 소유 pane 행만 pin할 수 있고, PID 확인이 불가능하거나 불일치하면 어느 행도 pin하지 않습니다.
|
||||
- `test_agy_capture_requires_presence_owned_by_pane_pid`: lsof 결과를 모의해 일치 PID/자손, 다른 PID, lock 미점유, lsof 오류를 검증합니다.
|
||||
- `test_agy_capture_rejects_cache_uuid_owned_by_sibling_in_same_cwd` 및 `test_agy_concurrent_onboard_same_cwd_no_cross_pin`을 추가합니다. sibling 배제 검사는 cache-hit에서도 실행되어야 합니다.
|
||||
- Issue #64의 `verify_session_uuid(...uuid_b...) is True` 기대를 `False`로 바꾸고, 외부 DB에 sidecar가 생기지 않는 확인은 유지합니다.
|
||||
|
||||
### 2단계 — adapter 및 agy 판정
|
||||
|
||||
- lifecycle 선언, capture 결과 타입, epoch/workspace 단일 후보 규칙을 테스트합니다.
|
||||
- A/B 캐시 전환, 타 workspace mapping, 같은 cwd sibling claim, cache match여도 sibling UUID 거부, presence 소유자가 pane 또는 자손인지 확인합니다.
|
||||
- lsof를 쓸 수 없는 상황에서는 `unverifiable-owner` 등 사유와 함께 미확정임을 확인합니다. 어떤 예외 경로도 cache-only 자동 pin으로 되돌아가면 안 됩니다.
|
||||
|
||||
### 3단계 — 공용 completed handler
|
||||
|
||||
- daemon 없는 onboard 완료, OpenCode/Codex/Hermes의 첫 completed pin, 기존 ID/pre-assigned skip, remote/human/stale token/non-completed 제외를 확인합니다.
|
||||
- wrapper completed와 loop 복구 완료를 같은 키로 호출해 capture 및 저장이 한 번뿐인지 검증합니다.
|
||||
- 모호성·검증 실패의 사유가 보존되고 자체 ID는 그대로인지 확인합니다.
|
||||
|
||||
### 4단계 — reconcile 통합 및 회귀
|
||||
|
||||
- drift C가 공용 판정기를 사용하고 agy의 전체 DB glob 후보를 자동 pin에 쓰지 않는지 테스트합니다.
|
||||
- same-cwd A/B에서 B의 최신 cache 값과 A의 더 최근 epoch가 있어도 lock 소유 pane이 A라면 A만 pin될 수 있고, B는 pin되지 않는지 검증합니다.
|
||||
- subscriber, retained/disk fallback, daemon 없는 실행, loop 복구 경로에서 동일 dedupe key를 확인합니다. publisher가 제어-plane 상태를 쓰지 않는 것도 확인합니다.
|
||||
|
||||
### 5단계 — 검증과 범위 마무리
|
||||
|
||||
- 지정 Python 환경에서 신규 Issue #40 테스트, 갱신된 Issue #64, UUID/tier2/dispatch 및 관련 adapter/reconcile 테스트를 실행합니다. 전체 테스트는 상위 오케스트레이션에 맡깁니다.
|
||||
- 테스트는 임시 HOME/DB와 source 바깥 basetemp를 쓰고 실제 agent home, `.mam.env`, monitor/herdr 서비스에 접근하지 않습니다. 변경한 shell은 `bash -n`, diff는 `git diff --check`로 확인합니다.
|
||||
- 작업 전후 branch/status를 확인하고 요청 범위 파일만 stage합니다. 버전 파일 및 `.venv`는 추가하지 않습니다.
|
||||
|
||||
## 위험과 명시적 한계
|
||||
|
||||
가장 큰 위험은 잘못 pin된 ID가 잘못된 resume 또는 purge로 이어지는 것입니다. 따라서 agy에서 lock 소유 프로세스와 pane의 관계를 확인할 수 없으면 자동 pin을 포기합니다. 이는 일부 환경에서 정상 대화도 자동 등록되지 않는 false negative를 허용하는 fail-closed 선택입니다. 추후 에이전트가 presence lock을 열지 않는 지원 모드가 확인되면 별도의 식별 가능한 프로세스 귀속 신호를 설계해야 하며, 기존 cwd 캐시를 대체 증거로 승격하지 않습니다.
|
||||
|
||||
같은 cwd의 활성 agy 세션 수가 많을수록 cache-only 판정은 신뢰할 수 없습니다. viewport 검사, epoch, 최신 mtime은 owner 증명이 아닙니다. stop의 기존 capture와 source/verified 메타데이터 정합성은 별도 후속 범위로 남기되, completed pin이 이를 덮어써 기존 ID를 변경하지 않도록 회귀 테스트합니다.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261008T133858Z-9e7f5b",
|
||||
"base_sha": "6b00b6e06f55299cb60f156ccef09ede4bae5bdd",
|
||||
"head_sha": "ac48cb78617af88584db529b5766acf02f4af73c",
|
||||
"tree_sha": "b043034eda891bd83b547dcb34d7422a6977b89d",
|
||||
"review_commit": "ac48cb78617af88584db529b5766acf02f4af73c",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "05537aa1",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-08T18:40:20Z"
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# Review — 05537aa1
|
||||
|
||||
## 판정
|
||||
|
||||
PASS. 이전 라운드에서 빠진 O1/O2의 run_loop 실행 경로에 격리가 추가되었으며, 새로운 차단 결함은 확인하지 못했다. 브리프가 허용한 정적 검토와 제한 환경 실행으로 판정했다. 전체 통합 스위트 통과를 주장하지 않는다.
|
||||
|
||||
## Identity 및 변경 범위
|
||||
|
||||
- loop: `loop-20261008T133858Z-9e7f5b`
|
||||
- base: `6b00b6e06f55299cb60f156ccef09ede4bae5bdd`
|
||||
- head: `ac48cb78617af88584db529b5766acf02f4af73c`
|
||||
- tree: `b043034eda891bd83b547dcb34d7422a6977b89d`
|
||||
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261008T133858Z-9e7f5b/3`
|
||||
- Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/issue17-wsroot/.venv/bin/python` (3.9).
|
||||
|
||||
위 identity를 확인했다. 검증 전후 git status가 비어 있고 `git diff --check 6b00b6e HEAD` 및 변경된 Python 17개 파일 AST 파싱이 통과했다. 제품 코드 변경은 없다. creator 작업 트리에서는 실행/수정하지 않았고 검증 소스도 수정하지 않았다.
|
||||
|
||||
이전 검토 head `8b47cc76eed4582b1848b2eec529523a61497320` 이후 diff는 다음 세 파일뿐이다:
|
||||
|
||||
1. `tests/test_o1_rebuttal.py:10` — 모듈에 own_workspace_root fixture opt-in 추가.
|
||||
2. `tests/test_o2_race_free_lock.py:196` — run_loop를 실행하는 O2-12에만 fixture opt-in 추가.
|
||||
3. `tests/test_issue17_workspace_root_isolation.py:41` — W1의 nested pytest 목록에 O1-V3와 O2-12 추가.
|
||||
|
||||
O2 모듈 전체에 fixture를 적용하지 않은 것은 적절하다. `test_o2_7_no_leftover_temp_files`는 tmp_path가 비어 있어야 하므로 새 workspace 디렉터리가 만들어지면 불필요한 실패가 된다. O2-7은 이번 두 실행에서 모두 통과했다. 나머지 O2 잠금 함수 테스트는 lib.sh가 아닌 loop_lock.sh를 source한다.
|
||||
|
||||
## 이전 보고서 정정 및 누적 검토
|
||||
|
||||
이전 c20f3d1f 보고서의 O2 제외 근거는 일부 상태 helper에만 해당했으며, `test_o2_12_run_loop_exits_on_lock_failure`의 직접 run_loop 실행까지 포괄하지 못했다. 그 제외 설명과 '추가 동일 결함 없음'이라는 결론은 범위가 너무 넓었다. 이번에는 O1-V3와 O2-12의 이전 코드가 실제 sentinel에 shim을 생성하는 것을 확인했고, 현재 추가된 fixture로 격리되는 것을 확인했다.
|
||||
|
||||
기존 누적 수정은 이전 head와 동일하다:
|
||||
|
||||
- `test_issue17_item4_decisions.py:224`: `_env`에서 상속 WORKSPACE_ROOT를 제외하고 tmp_path로 고정. unset만 하면 lib.sh가 checkout을 기본값으로 쓰므로 명시적 임시 root가 맞다.
|
||||
- `conftest.py:180`: opt-in own_workspace_root. 기존 mam_sandbox와 테스트 본문의 명시적인 root 설정이 나중에 적용되는 구조를 유지한다.
|
||||
- `test_orc_onboard.py:19,27`: import 시점 캐시에서 WORKSPACE_ROOT를 제거하고 호출 시점의 root를 적용한다.
|
||||
- `test_workspace_scope.py:31`, `test_issue21_worktree_prereqs.py:544`: fixture opt-in 대신 해당 helper의 임시 root를 직접 지정한다.
|
||||
- b19/b8/loop CLI/phase3/phase4/O3/version/issue25/fail_closed 모듈의 셸 실행은 own_workspace_root를 사용한다.
|
||||
|
||||
이전 head에 대한 상세 fixture 우선순위 검증과 135 passed × 2, 추가 84 passed, 별도 3 passed 결과는 다음 이전 보고서에 있다. 이를 현재 head에서 새로 실행한 결과로 세지 않았다:
|
||||
`/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.mam/jobs/c20f3d1f/codex-reports/report-final.md`
|
||||
|
||||
## 이번 실행
|
||||
|
||||
환경에서 HERDR_*, MAM_*, MQTT_*, SKS_*, WORKSPACE_ROOT 및 control-plane 경로 변수를 제거하고 HOME을 임시 경로로 지정했다. sentinel은 미리 생성한 빈 디렉터리다. 모든 산출물과 basetemp는 검증 트리 밖에 두고 bytecode/cache 쓰기를 껐다. 실제 .mam.env를 export하거나 출력하지 않았다.
|
||||
|
||||
샌드박스는 MQTT 연결/로컬 broker socket bind 및 ps 실행을 제한한다. 따라서 `/tmp/05537aa1-run.py`는 현재 검증 트리의 실제 conftest를 import하여 local_mqtt_broker fixture만 메모리에서 제외하고, `--noconftest`, `-p no:cacheprovider`, 임시 basetemp와 `plugins=[module]`로 실행했다. 나머지 fixture/hook/상태 guard는 그대로 등록했다. 소스 파일 변경이나 권한 우회는 하지 않았다.
|
||||
|
||||
실행 대상:
|
||||
|
||||
- `tests/test_o1_rebuttal.py` 전체.
|
||||
- `tests/test_o2_race_free_lock.py` 전체.
|
||||
- `test_issue17_workspace_root_isolation.py::test_w2_onboard_does_not_carry_its_import_time_root`.
|
||||
- `test_issue17_item4_decisions.py::test_c_helpers_write_nothing_to_an_inherited_workspace`.
|
||||
|
||||
| 조건 | 결과 | sentinel 상태 |
|
||||
|---|---|---|
|
||||
| WORKSPACE_ROOT unset | 24 passed, 10 failed, 6.54s | 0 entries |
|
||||
| WORKSPACE_ROOT=sentinel | 24 passed, 10 failed, 6.56s | 0 entries |
|
||||
|
||||
O1의 10개 테스트와 두 기존 격리 회귀 테스트는 모두 통과했다. 실패한 O2 테스트는 두 조건에서 동일하게 O2-2/3/4/11/12/16/17/18/19/20이다. `loop_lock.sh:39` 부근에서 자신의 ps lstart를 얻지 못하면 holder state가 unknown이 되고 `:108` 부근에서 rc 4를 반환한다. 따라서 live/stale 판정을 기대하는 테스트가 실패한다. O2-12 로그도 명확히 `Cannot determine whether another loop is running (ps unavailable?)`를 출력한다. 이는 이번 WORKSPACE_ROOT 수정으로 잠금 동작이 바뀐 것이 아니다.
|
||||
|
||||
기능 assert가 ps 제한으로 실패한 O2-12도 lib.sh bootstrap은 실행된다. 실행 후 O1-V3와 O2-12 각각의 `.mam-test-state/workspace/.mam/shim/herdr`가 존재하고 외부 sentinel이 비어 있음을 두 조건에서 직접 확인했다. 검증 checkout의 `.mam` 아래에도 항목이 없다.
|
||||
|
||||
로그/드라이버:
|
||||
|
||||
- `/tmp/05537aa1-run.py`
|
||||
- `/tmp/05537aa1-unset.log`
|
||||
- `/tmp/05537aa1-sentinel.log`
|
||||
- unset root: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/05537aa1-unset-ecvw8t06`
|
||||
- sentinel root는 `/tmp/05537aa1-sentinel.log` 첫 ROOT 행에 기록되어 있다.
|
||||
|
||||
## 수정 전 실패 확인
|
||||
|
||||
`/tmp/05537aa1-negative.py`가 `git show`로 읽은 이전 코드를 메모리에 로드했다. __file__은 현재 검증 트리 경로로 지정하고, 실행되는 제품 코드는 현재 검증 트리의 변경 없는 코드다. 검증 트리를 checkout/reset하거나 수정하지 않았다.
|
||||
|
||||
| 시나리오 | 수정 전 실제 결과 | 현재 실제 결과 |
|
||||
|---|---|---|
|
||||
| base `_env`를 현재 decisions 회귀 함수에 적용 | `AssertionError: written into the sentinel` | 동일 회귀 함수 통과 |
|
||||
| onboard를 sentinel root에서 import한 후 fixture root로 변경하고 run_find_uuid 호출 | base: sentinel 3 entries, own shim 없음 | head: sentinel 0 entries, own shim 있음 |
|
||||
| 이전 head의 O1-V3 함수 직접 실행 | 함수 자체는 성공하지만 sentinel에 `.mam/shim/herdr` 생성 | pytest fixture 적용 시 own root에만 shim 생성 |
|
||||
| 이전 head의 O2-12 함수 직접 실행 | ps assert 실패 전 sentinel에 `.mam/shim/herdr` 생성 | 같은 ps 제약하에서도 own root에만 shim 생성 |
|
||||
|
||||
기대 결과는 모두 외부 sentinel 불변이다. 새 opt-in이 없던 O1/O2는 이 기대를 위반하고, 현재 pytest 실행에서는 만족한다. O1/O2 negative control은 함수를 직접 호출한 탐침이며 W1 전체 nested pytest의 성공/실패 결과라고 표현하지 않는다.
|
||||
|
||||
negative log: `/tmp/05537aa1-negative.log`
|
||||
negative root: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/05537aa1-negative-_c2j_av4`
|
||||
|
||||
W1은 actual conftest/broker를 사용하는 nested pytest이므로 이 샌드박스에서 완주하지 않았다. 새 두 node가 실제 수집 가능한 함수명과 일치하며, subprocess 성공 및 통과 수 검사 뒤 copied checkout의 `.mam` 비어 있음을 검사하는 구조를 정적으로 확인했다.
|
||||
|
||||
## 같은 결함 후보 재조사
|
||||
|
||||
환경 복사만 검색하지 않고 `run_loop.sh`, `lib.sh`, WRAPPER, source, subprocess 실행도 교차 검색했다. 아래는 이번에 재확인한 미수정 후보의 제외 이유다. 이전 보고서의 O2 포괄 제외는 이 표로 대체한다.
|
||||
|
||||
| 파일:라인 | 미수정 이유 |
|
||||
|---|---|
|
||||
| tests/test_o2_race_free_lock.py:14,23 | 일반 lock helper는 loop_lock.sh만 실행. 직접 run_loop를 실행하는 :199는 이번에 수정됨. |
|
||||
| tests/test_herdr_shim_contract.py:10,139 | lib.sh를 실행하는 계약 테스트/공유 helper 호출은 mam_sandbox/mock fixture를 사용한다. |
|
||||
| tests/test_sanitize_and_mock_errors.py:28 | bash parity test가 mam_sandbox를 사용한다. |
|
||||
| tests/test_issue7_workspace_packing.py:49 | 셸 helper 호출이 mam_sandbox 기반이다. |
|
||||
| tests/test_issue21_acceptance.py:280 | run_loop 실행 환경에 임시 main root를 명시한다. |
|
||||
| tests/test_issue26_envfile.py:73 | `_env(home, ...)`가 HOME과 WORKSPACE_ROOT를 home으로 설정한다. |
|
||||
| tests/test_issue30_stall_watch.py:124,231 | box/mam_sandbox와 격리된 환경 helper를 사용한다. |
|
||||
| tests/test_issue30_stall_watch_stop.py:29 | `_env(home)`이 WORKSPACE_ROOT=home을 지정한다. |
|
||||
| tests/test_issue45_restore.py:93,172 | run_lib의 rig는 mam_sandbox에 의존한다. 실제 서버 테스트는 이번 리뷰에서 실행하지 않았다. |
|
||||
| tests/test_issue24_resume_dialogs.py:70,82 | create/stop/resume 실행이 mam_sandbox/mock fixture를 사용한다. |
|
||||
| tests/test_issue15_registry_isolation.py:75 | 별도 subprocess 환경에 임시 WORKSPACE_ROOT를 명시한다. |
|
||||
| tests/test_issue5_phase0_contracts.py:481 | run_loop 언급은 정적 문서 계약 검사다. |
|
||||
| tests/test_issue14_delivery_ack.py:33; test_issue18_liveness_contract.py:54 | 환경 helper에서 cwd를 root로 지정한다. |
|
||||
| tests/test_issue34_job_tree.py:50; test_issue36_prompt_transport.py:47 | 환경 helper가 임시 home/root를 고정한다. |
|
||||
| tests/test_c1_tui_readiness.py:12; test_layout.py:310 | 별도 임시 root를 source 전에 지정한다. |
|
||||
| tests/test_tier1_unit.py:14; test_issue5_phase1_delegation.py:49 | 셸 helper 호출은 mam_sandbox를 사용한다. |
|
||||
| tests/test_b7_diff_untracked.py:11 | diff_collect는 lib.sh의 shim bootstrap 경로가 아니다. |
|
||||
|
||||
추가 동일 결함은 확인하지 못했다. 이번 변경은 pytest fixture 사용 추가여서 Bash 제품 문법을 바꾸지 않는다. O1의 bash 3.2 empty-array 검증도 이번 실행에 포함되어 통과했다.
|
||||
|
||||
## 검증 한계 / Lifecycle
|
||||
|
||||
새 변경은 3개 파일이므로 그 변경과 회귀 탐침을 재검증했으며, 이전 head와 동일한 전체 파일 묶음은 반복하지 않았다. 변경 파일 전부와 issue33_budget/phase1_delegation/tier1 전체의 두 환경 통합 실행을 이번 리뷰가 모두 통과시켰다는 주장은 하지 않는다. 제한 없는 전체 실행은 브리프에 따라 오케스트레이터가 수행해야 한다. 환경 제한 자체를 NOT PASS 사유로 삼지 않았다.
|
||||
|
||||
started 명령을 먼저 실행했으나 MQTT 연결이 Operation not permitted로 3회 실패하여 exit 2였다. 보고서 작성 후 요청된 completed 명령을 실행한다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,128 @@
|
||||
# PR #59 Mac review — NOT PASS
|
||||
|
||||
Job `05572944`, role Reviewer. 소스 수정 없이 검토했다. 선택 테스트 총 **80 passed, 1 skipped**이지만, 추가 탐침에서 삭제 범위 문제 두 건과 live-process 판정 문제 한 건을 확인했다. 병합 전 수정이 필요하다. 국소적인 안전성 수정 및 회귀 테스트로 해결 가능하므로 재계획 에스컬레이션은 하지 않는다.
|
||||
|
||||
## Review identity
|
||||
|
||||
- loop: `loop-20261016T003524Z-a51548`
|
||||
- base: `26e2c43753169671b600d03274901dbe203988dd`
|
||||
- head: `6f4f40a4adb2115d5997dffd31fb6473f05cab68`
|
||||
- tree: `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8`
|
||||
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1`
|
||||
- 범위: base..head 13 commits / 12 files. 아래 파일:라인은 이 head 기준이다.
|
||||
- PR #59 본문과 issue #31을 tea의 읽기 명령으로 확인했다.
|
||||
|
||||
## R1 — [P1] 상위 디렉터리의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다
|
||||
|
||||
근거:
|
||||
|
||||
- `.agents/skills/lib_py/agents/adapters/claude.py:279`–`:284`: tmp root의 모든 entry를 key로 간주하고 `root/key/uuid`를 후보에 넣는다. key가 외부 디렉터리를 가리키는 링크인지 검사하지 않는다.
|
||||
- 같은 파일 `:80`–`:85`: `_remove`는 최종 path 자체가 링크인지 확인하지만, 상위 key가 링크이면 최종 uuid 디렉터리는 일반 디렉터리로 보인다. 따라서 `shutil.rmtree`가 링크 너머 실제 디렉터리를 삭제한다.
|
||||
- 같은 파일 `:266`–`:270`의 projects/file-history/session-env 경로에도 상위 구성요소 경계 검증은 없다.
|
||||
|
||||
재현(실제 홈/스크래치패드는 사용하지 않음):
|
||||
|
||||
1. 임시 config/HOME/workspace/tmp를 만들고 새 UUID U를 생성한다.
|
||||
2. 임시 소유 루트 밖인 `<review-temp>/outside-owned-roots/U/keep.txt`를 만든다. 이는 테스트용 sentinel이며 실제 사용자 파일이 아니다.
|
||||
3. 임시 `<ctmp>/claude-<uid>/linked-workspace`를 `outside-owned-roots`에 연결한다.
|
||||
4. `_tmp_roots`를 이 임시 claude root 하나로 제한하고 `ClaudeAgentAdapter.purge_artifacts(U, ctx)`를 실행한다. 제한은 실제 `/tmp/claude-<uid>`에 접근하지 않도록 하는 시험 장치이며 경로 열거/삭제 로직은 변경하지 않는다.
|
||||
|
||||
기대: 소유 경계 밖 sentinel은 보존되어야 한다. 최소한 링크를 경유한 후보를 거부하고 residual/경고로 남겨야 한다.
|
||||
|
||||
실제:
|
||||
|
||||
```text
|
||||
ANCESTOR_SYMLINK {"victim_exists": false, "link_survives": true,
|
||||
"purged": [".../ctmp/claude-501/linked-workspace/<U>"]}
|
||||
```
|
||||
|
||||
상위 링크 자체는 남고 링크 너머 디렉터리가 삭제됐다. 최종 UUID entry 자체를 symlink로 만든 대조군에서는 target이 살아 있고 링크만 삭제됐다. 즉 leaf symlink 보호만으로 해결되지 않는 문제다.
|
||||
|
||||
수정 방향: 명시적으로 설정한 root를 신뢰 경계로 정규화한 뒤, 그 아래 탐색 중 만난 symlink 디렉터리를 따라가지 않는다. tmp의 workspace key 등 중간 경로도 `lstat`/no-follow로 확인해야 한다. 경계 밖 realpath 후보는 삭제하지 말고 보고한다. 링크 교체 경합까지 고려하면 디렉터리 descriptor와 no-follow 연산으로 안전하게 경로를 여는 방식이 적절하다. leaf-link 대조군과 ancestor-link 탈출 회귀 테스트를 둘 다 추가한다.
|
||||
|
||||
## R2 — [P2] UUID가 아닌 `memory`를 세션으로 받아 공유 메모리 디렉터리를 삭제한다
|
||||
|
||||
근거:
|
||||
|
||||
- `.agents/skills/lib_py/agents/base.py:89`–`:92`: `_plausible_id`는 일부 경로 문자만 배제한다. `memory`, 공백, 임의 문자열은 UUID가 아니어도 통과한다.
|
||||
- `.agents/skills/lib_py/agents/adapters/claude.py:262`–`:268`: 이 검증만으로 `projects/<key>/<입력>` 디렉터리 전체를 소유 경로로 간주한다.
|
||||
- 같은 파일 `:287` 및 `:296`: `has_artifacts`와 purge가 같은 오판을 공유한다. 기존 코드의 단일 `<uuid>.jsonl` 삭제와 달리 이제 디렉터리 전체 삭제로 이어진다.
|
||||
|
||||
재현:
|
||||
|
||||
1. 임시 config의 `projects/<workspace_key>/memory/MEMORY.md`에 공유 메모리 sentinel을 만든다.
|
||||
2. `adapter.has_artifacts("memory", ctx)`를 호출한다.
|
||||
3. `adapter.purge_artifacts("memory", ctx)`를 호출한다.
|
||||
|
||||
기대: Claude 세션 UUID가 아닌 입력을 거부하고 memory를 보존해야 한다.
|
||||
|
||||
실제:
|
||||
|
||||
```text
|
||||
MEMORY_ACCEPTED True
|
||||
NON_UUID {"memory_exists": false,
|
||||
"purged": [".../home/.claude/projects/<workspace_key>/memory"]}
|
||||
```
|
||||
|
||||
현재 malformed-id 테스트는 빈 값, 점, 별표, slash만 다루므로 이 경우를 검출하지 않는다. 정상 UUID를 purge할 때 memory를 보존하는 테스트가 통과하는 것과는 별개다.
|
||||
|
||||
한계/구분: 이 호스트에서 실행한 **별도의 무수정 CLI** `purge claude memory` 탐침은 일반 `conversation_in_use`의 프로세스 문자열 검사에 걸려 rc 4로 끝났고 memory를 보존했다. 따라서 CLI가 이 실행에서 memory를 삭제했다고 주장하지 않는다. 위 삭제는 어댑터 호출에서 직접 재현했다. 프로세스 목록에 우연히 `memory`가 등장하는지 여부는 UUID 검증이나 공유 파일 소유권 보장의 대안이 아니며, CLI target 탐색도 이 잘못된 `has_artifacts`에 의존한다.
|
||||
|
||||
수정 방향: Claude의 디렉터리 삭제 후보를 만들기 전에 Claude 세션 식별자 형식을 검증한다(예: UUID 파싱 및 허용된 표기 검증). 다른 어댑터의 `ses_...` 식별자까지 전역적으로 금지하지 말고 Claude에 맞는 검증을 적용한다. `memory`, 공백, 임의 비-UUID 문자열은 has/purge에서 거부하고 실제 공유 memory 파일이 살아 있음을 회귀 테스트로 고정한다.
|
||||
|
||||
## R3 — [P2] NaN startedAt을 판정 불가로 처리하지 않아 live guard가 열린다
|
||||
|
||||
근거:
|
||||
|
||||
- `.agents/skills/lib_py/agents/adapters/claude.py:70`: `float(data["startedAt"])`가 `"NaN"`과 JSON NaN을 예외 없이 변환한다.
|
||||
- 같은 파일 `:73`, `:77`: NaN은 `<= 0`도 거짓이고 `lstart <= started + 2`도 거짓이므로, 살아 있는 PID에 대해 False를 반환한다.
|
||||
- 같은 파일 `:275`: 그 세션 파일은 죽은 프로세스의 소유 경로로 선택된다.
|
||||
- `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh:292`–`:298`: in-use rc 0이면 purge gate를 통과한다.
|
||||
|
||||
재현:
|
||||
|
||||
1. 실제 Claude 대신 `/bin/sleep 60`을 띄운다.
|
||||
2. 임시 config의 `sessions/<sleep-pid>.json`에 새 UUID, 해당 live pid, `"startedAt": "NaN"`을 기록한다.
|
||||
3. 모든 Claude/home/tmp 환경을 임시 위치로 지정한 CLI `python -m lib_py.agents in-use claude <UUID> <temp-workspace>`를 실행한다.
|
||||
|
||||
기대: 시작 시각을 비교할 수 없으므로 보수적으로 live=True, CLI rc 4.
|
||||
|
||||
실제:
|
||||
|
||||
```text
|
||||
CLI_NAN {"rc": 0, "stdout": "{\"agent\": \"claude\", \"in_use\": false}\n",
|
||||
"holder_alive": true, "session_file_exists": true}
|
||||
```
|
||||
|
||||
직접 함수 탐침에서도 string NaN과 JSON NaN은 둘 다 False였고, `_owned_paths`에는 이 live PID의 세션 파일이 포함됐다. live fixture를 실제로 purge하지는 않았다.
|
||||
|
||||
수정 방향: startedAt 변환 후 `math.isfinite` 및 유효 범위를 확인하고, 비유한 값이면 True(live/unknown)를 반환한다. 필요하면 bool 등 시각이 아닌 타입도 배제한다. NaN/무한대/결손/파싱 불가 입력과 정상 live/dead/reused PID를 함께 검사한다.
|
||||
|
||||
## Mac 검증 및 통과한 범위
|
||||
|
||||
- `tests/test_issue31_purge_residual.py`: **32 passed in 23.89s**, rc 0.
|
||||
- `tests/test_issue42_skill_blocks.py`, `tests/test_issue55_agent_homes.py`: **48 passed, 1 skipped in 1.48s**, rc 0. skip은 리뷰 worktree에 untracked `.claude/skills`가 없는 경우이다.
|
||||
- base `26e2c43`의 임시 archive 복사본에 새 테스트 파일을 올려 핵심 3건을 실행: `test_claude_purge_removes_every_owned_path`, `test_claude_residual_lists_leftovers_then_nothing`, `test_purge_cli_refuses_a_live_claude_without_uuid_on_argv` 모두 예상대로 실패. 현재 head에서는 통과한다. 전체 32건의 base 비교를 다시 수행한 것은 아니다.
|
||||
- 실제 `/usr/bin/python3` 3.9.6에서 변경 Python 8개 AST 파싱 통과. 변경 shell 2개를 각각 `/bin/bash -n`으로 검사하여 통과. 실제 Bash 3.2로 stop/in-use fixture 테스트 실행.
|
||||
- 실제 BSD `/bin/ps -p <sleep-pid> -o lstart=` + LC_ALL=C 출력 예: `Fri Oct 16 10:18:16 2026`. 정상 현재 startedAt은 live=True, 한 시간 이전 startedAt은 PID 재사용으로 판정되어 live=False, 결손은 unknown/live=True. 파싱 불가 ps 출력의 보수적 처리는 신규 테스트로 통과했다. NaN은 위 별도 결함이다.
|
||||
- 정상 UUID의 다른 세션/동일 jobs 접두사의 다른 세션/shared history 보존, stale session/key 삭제, 부분 삭제 실패의 소유 증거 유지, CLAUDE_CONFIG_DIR, stop rc 4 및 행 보존/삭제, codex/grok/opencode residual은 새 테스트 범위에서 통과했다.
|
||||
- stop SKILL.md의 row 제거 설명과 `skill_code_blocks.json` 변경은 코드/문서 검사와 일치했다.
|
||||
- `git diff --check base HEAD` 통과. 검증 후 HEAD/tree 일치 및 clean git status 확인.
|
||||
|
||||
## 실제 구조 관찰과 격리 범위
|
||||
|
||||
실제 홈에서는 디렉터리 목록/파일 종류/존재 여부만 읽었다. JSON, key, transcript 내용은 열지 않았다. 관찰 당시 `~/.claude`에는 projects 16개 디렉터리, file-history 18개, session-env 21개, sessions의 JSON 5개/key 5개가 있었다. jobs의 표본 디렉터리 두 곳에 state.json이 존재했다. `/private/tmp/claude-501`에는 workspace 디렉터리 5개가 있었고 표본에서 `<key>/<session>/scratchpad` 구조를 확인했다. `/tmp`의 realpath는 `/private/tmp`, 이 Mac의 TMPDIR realpath는 `/private/var/folders/.../T`였다. 이 관찰은 디렉터리 형태를 확인한 것이며 실제 session JSON 필드 값을 검증했다는 뜻은 아니다.
|
||||
|
||||
브리프의 금지 범위를 지키기 위해 테스트는 다음과 같이 격리했다.
|
||||
|
||||
- 지정 `env -u` 목록(HERDR/WORKSPACE_ROOT/MAM/DELEGATE/CLAUDE_CONFIG_DIR)을 적용하고, HOME/HOME_DIR 및 Claude 프로젝트·tmp 경로를 임시 위치로 지정했다. config 기본값 테스트는 임시 HOME의 `.claude`를 사용하며 custom config 테스트도 임시 경로만 사용했다.
|
||||
- 코드가 명시적 임시 환경과 무관하게 `/tmp/claude-<uid>`도 탐색하므로, 테스트용 sitecustomize에서 **그 실제 fallback root에 대한 isdir만 False로 처리**했다. 그 실제 root에 대한 remove/rmdir/rmtree/rename도 방어적으로 차단했다. 이 테스트 보조 파일은 임시 디렉터리에만 있고 제품 소스는 변경하지 않았다.
|
||||
- 따라서 tmp root 단위 테스트는 실제 fallback을 제외한 격리 환경에서 통과한 것이다. 실제 fallback의 존재/레이아웃은 위 읽기 관찰로 별도 확인했다.
|
||||
- 수동 purge 탐침의 `_tmp_roots`는 임시 root 하나로 제한했다. CLI 탐침도 동일한 real-fallback 차단을 적용했다. 삭제 sentinel은 전부 리뷰 임시 디렉터리 안의 합성 데이터다.
|
||||
- 실제 Claude 프로세스, herdr 서버, monitor는 시작하지 않았다. 실제 홈/실제 scratchpad/실제 agent session에 purge 또는 in-use를 실행하지 않았다. 제어 평면 쓰기는 지시된 lifecycle 이벤트와 본 리포트뿐이다.
|
||||
- 모든 pytest 실행에 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp를 사용했다. creator 트리에서 실행하지 않았다.
|
||||
- 전체 스위트는 오케스트레이터의 별도 실행 범위이며 이번 리뷰에서 재실행하지 않았다. 선택 테스트에서는 #55 오탐/teardown 오류가 없었다.
|
||||
|
||||
산출물: `/tmp/review-05572944-u3lt5wk_/`의 `pytest.log`, `docs-home.log`, `before.log`, `probes.py`, `probes.log`, `cli-probe.py`, `cli-probe.log`, `guard/sitecustomize.py`. 임시 스크립트는 해당 검증 트리 cwd에서 실행하도록 작성됐으며 모든 파괴적 동작은 자체 임시 환경을 만든 후 수행한다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260921T150212Z-97d845",
|
||||
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
|
||||
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
|
||||
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
|
||||
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "08808795",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-21T15:25:44Z"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# PR #32 independent review
|
||||
|
||||
Reviewer: planner-reviewer-codex-01. Job: 08808795.
|
||||
|
||||
Reviewed the exact detached review-identity path specified by the brief:
|
||||
`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1`, at `1e826515`, for the five commits after base `6fc9194`. No checkout, source file, session, monitor, or real registry state was modified.
|
||||
|
||||
## Findings
|
||||
|
||||
The explicit plan-reuse change is coherent and closes the implicit-load defect.
|
||||
|
||||
- With neither `--plan` nor `--plan-file`, Python self-planning logs that no plan is loaded and does not glob `.agents/reports/<planner>/plan-*.md`. The legacy shell runner does the same. `resolve_planner_session` may still identify a planner for other loop bookkeeping, but it no longer supplies a plan through that path.
|
||||
- `--plan-file` is parsed by both wrappers. Python resolves relative paths against the control root, preserves absolute and `~` paths, requires a readable regular file with non-whitespace content, and rejects bad inputs before registering jobs. It is mutually exclusive with `--plan`; `--planner` still requires `--plan`; review-only accepts a valid file but warns and ignores it because no implementation prompt is produced. The legacy shell runner rejects the option explicitly when `MAM_LOOP_PY=0`, before the loop lock.
|
||||
- `_plan_text()` is the shared check for explicit files, initial planner reports, discussion refinements, and escalation refinements. An empty initial planner report stops the run rather than silently falling back to self-planning. An empty refine report preserves the existing plan. Correction prompts therefore never claim to carry an empty plan.
|
||||
- Checkpoint `current_plan` is not a plan-discovery source. It is only recorded for the current run; a later no-option run starts planless, and a named `--plan-file` is read explicitly. Promotion still writes the plan only for an actual `--plan` run.
|
||||
- Relative plan paths with spaces, missing paths, directories, empty/whitespace files, unreadable files, `--plan` conflicts, review-only handling, and legacy-shell rejection are covered by P1–P11. The tests run in temporary control roots and use isolated reports; they do not write the real `.mam` or `.agents/reports`.
|
||||
- The loop skill’s old implicit-reuse wording is removed. The CLI usage, workflow examples, option table, and explicit-reuse description agree. No `.claude/skills` deployment copy exists in this checkout, so there is no second copy to synchronize.
|
||||
|
||||
The known release prerequisite remains outside this PR: the orchestrator must perform the 4.7.1 lockstep version bump and release entry before merge.
|
||||
|
||||
## Verification
|
||||
|
||||
All tests used `PYTHONDONTWRITEBYTECODE=1`, the interpreter from the brief, pytest cache disabled, and a basetemp outside the review path.
|
||||
|
||||
- `tests/test_issue25_plan_reuse.py`: **17 passed** in 1.70 s.
|
||||
- `tests/test_issue25_plan_reuse.py`, `tests/test_loop_cli.py`, `tests/test_issue5_phase3_loop.py`, and `tests/test_version_consistency.py`: **39 passed, 2 failed** in 49.25 s when run exactly from the identity path. Both failures occurred before their assertions because `run_loop.sh` could not find `.venv` in the detached snapshot and fell back to system Python, which lacked `paho` (`RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'`). This is a snapshot/setup issue, not a change in the plan-reuse code.
|
||||
- Re-running the two failed compatibility cases with `AGENT_PYTHON_BIN` pinned to the brief’s main interpreter: **2 passed** in 0.32 s. This reaches and verifies the expected session-registration checks.
|
||||
- `git diff --check 6fc9194...HEAD`: passed.
|
||||
|
||||
The focused plan tests passed without modification, and the only broader failures disappeared when the required interpreter was explicitly supplied. I found no additional merge-blocking defect beyond the known version gate.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,103 @@
|
||||
# PR #41 독립 리뷰 — 0f12973f
|
||||
|
||||
**NOT PASS.** 알림 전용 원칙과 두 종료 시계의 분리는 대체로 구현됐지만, 감시자의 래퍼 종료 처리와 완료 후 재주입 방지에 재현 가능한 결함이 있다. 아래 R1·R2를 수정한 뒤 재검토해야 한다. 전체 설계를 다시 짤 수준은 아니며, 설계 합의 의견이나 예정된 4.8.0 버전 변경을 판정 근거로 삼지 않았다.
|
||||
|
||||
## 검증 identity와 범위
|
||||
|
||||
- loop: `loop-20260926T025230Z-a30a6d`
|
||||
- base: `8ea65bb23830b56c4530897270edaa6e556de392`
|
||||
- head: `1673818bb5fed14ed6fee87884e70d39e81fe91f`
|
||||
- tree: `00d5e03e2d521c87c3474c032f2af6f7a1af9e92`
|
||||
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T025230Z-a30a6d/1`
|
||||
- 30개 파일의 누적 변경을 검토했다. 검증 전후 HEAD/tree가 일치하고 `git status --porcelain`은 비어 있다. 소스 수정·커밋·실제 에이전트/herdr 세션 조작은 하지 않았다. 테스트·탐침의 레지스트리, HOME, 모의 herdr, 프로세스는 임시 디렉터리에 격리했다.
|
||||
|
||||
이하 파일:라인은 이 head 기준이며 `.agents/skills/` 아래 경로는 생략하지 않았다.
|
||||
|
||||
## 병합 차단 결함
|
||||
|
||||
### R1 · P2 — 래퍼가 SIGTERM으로 끝나면 stall watcher가 고아로 남는다
|
||||
|
||||
**근거:** `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:700`에서 감시자를 백그라운드로 실행하고 PID를 저장한다. 정리는 같은 파일 `:385`, `:560`의 정상 `wait` 반환 뒤에만 실행한다. `stop_stall_watch`는 `:708`부터 있지만 EXIT/TERM/INT 정리 trap에 연결돼 있지 않다. 기존 `:1088`의 EXIT trap은 agent bootstrap 전용이며 `:1102`, `:1108`, `:1114`에서 해제된다.
|
||||
|
||||
**재현:** 검증 트리의 실제 래퍼를 `MAM_DELEGATE_JOB_NO_MAIN=1`로 source한다. 임시 레지스트리에 UUID4에서 생성한 잡 ID와 `status=running`을 쓰고, PATH 맨 앞에 항상 `working` snapshot을 반환하는 모의 herdr를 둔다. 실제 `start_stall_watch herdr:isolated-probe codex`를 호출하고 래퍼가 해당 PID를 wait하도록 한 뒤 래퍼 PID에만 SIGTERM을 보낸다. 감시자는 실제 `python -m lib_py.stall_watch` 프로세스다.
|
||||
|
||||
**기대:** SIGTERM 같은 처리 가능한 래퍼 종료에서 감시자와 소유한 대기 프로세스를 정리하고 회수한다. 잡 상태는 감시자가 임의로 닫지 않는다.
|
||||
|
||||
**실제:** 래퍼 rc `-15` 뒤에도 `ps`가 감시자 `30374`, PPID `1`, 상태 `S`를 반환했다. 원래 잡이 열린 상태면 이 감시자는 계속 관측하고 나중에 알림·재주입할 수 있다. 정상 완료만 확인하는 `tests/test_issue30_stall_watch.py:503`의 S9로는 잡히지 않는다. 탐침 종료 후 탐침 전용 프로세스 그룹을 정리했고 해당 PID의 소멸을 확인했다.
|
||||
|
||||
**수정 방향:** 감시자 시작 전에 래퍼 소유 자식의 공통 EXIT/TERM/INT 정리를 설치하고 기존 bootstrap trap과 합성한다. kill과 wait는 bash가 맡되, 정상·오류·취소 경로 모두에서 실행되도록 한다. 감시자가 대기 중 생성한 herdr 자식도 유한 시간 안에 정리되는지 포함한 신호 종료 테스트를 추가한다. SIGKILL을 trap으로 처리하라는 요구는 아니다.
|
||||
|
||||
### R2 · P2 — herdr 대기 중 완료된 잡에도 idle 재주입을 실행한다
|
||||
|
||||
**근거:** `.agents/skills/lib_py/stall_watch.py:270`에서 잡 상태를 읽고, `:284`에서 최대 수십 초 대기한 뒤 `:310`에서 이전 상태 그대로 `_act`에 전달한다. `_act`는 에이전트 상태·자식·입력창만 새로 수집하며 잡 상태와 dispatch identity는 다시 확인하지 않는다. `:352`의 알림 뒤 `:354`는 무조건 `prompt`를 호출한다. `_notify`의 `:243` 부근은 발행 거부를 로그로만 남기므로 발행 CAS가 재주입을 막아주지도 않는다.
|
||||
|
||||
**재현:** 실제 `StallWatch.run()`에 모의 `wait_once`와 Source를 주입한다. 첫 대기에서 idle/seq=1을 관측한다. 두 번째 대기가 돌아오기 직전에 임시 잡 JSON을 `running → completed`로 바꾸고 TIMEOUT을 반환한다. 시계는 idle 임계값을 넘게 하고, collect는 같은 seq·살아 있는 PID·자식 없음·빈 입력을 반환한다. 알림/프롬프트만 메모리에 기록해 실제 발행과 세션 조작을 피한다.
|
||||
|
||||
**기대:** 완료를 재확인한 감시자는 종료하며 idle 알림·재주입을 하지 않는다.
|
||||
|
||||
**실제:** `run()`은 최종 rc 0으로 종료하지만 그 전에 `notifications=['stalled:idle']`, `prompts=[('completed', 'Job … otherwise continue where you left off.')]`를 기록했다. 즉 실제 prompt 호출 시점의 레코드는 이미 completed였다. 래퍼의 정상 stop과도 경합하고, R1로 남은 감시자에서는 더욱 명확히 발생한다. 동일 잡의 다음 dispatch에서도 status만 확인하는 현재 방식은 이전 token의 prompt를 차단하지 못한다.
|
||||
|
||||
**수정 방향:** 블로킹 관측 뒤와 재주입 직전에 잡의 비종결 상태 및 token/attempt 일치를 확인하고, 사라짐·불일치에서는 중단한다. 발행 결과를 구조화해 CAS 거부 이후에는 prompt를 보내지 않도록 한다. 완료/redispatch가 관측과 실행 사이에 끼어드는 결정적 테스트를 추가한다. registry 락을 긴 herdr RPC 동안 유지하는 방식은 피한다.
|
||||
|
||||
R1·R2의 실행 가능한 격리 탐침은 `/tmp/0f12973f-tools/probes.py`에 남겼다. 검증 cwd에서 브리프의 Python으로 이 파일과 위 검증 경로를 인자로 실행하면 재현된다. 탐침은 실제 서버 대신 자체 모의 herdr를 사용한다.
|
||||
|
||||
## 추가 개선 사항 — 비차단
|
||||
|
||||
1. **선행 0 예산 처리 불일치.** 래퍼 `:147`의 bash 산술은 `MAM_JOB_TIMEOUT_S=08`을 8진수로 해석하다 오류를 내고 `TIMEOUT=3600`으로 대체한다. 같은 값은 `lib_py/loop/runner.py:600`에서 8로 해석된다. 탐침 결과는 `value too great for base`와 `TIMEOUT=3600`이었다. 공통 Python 양의 정수 파서에서 십진수로 정규화하는 것이 낫다. 정상 정수·CLI 우선순위 테스트는 통과했다.
|
||||
2. **문서의 오래된 종료 설명.** delegate-job `SKILL.md:99`는 아직 total budget도 `deadline_timeout`이라 설명하고, `:101`의 broker-unavailable deadline 도달도 같은 라벨로 적는다. 구현은 두 경우 모두 wall clock이 소진되면 `wall_clock_timeout`이다. `:100`의 “before v4.7.5”도 이번 4.8.0 릴리스 계획과 맞춰 정리해야 한다. loop `SKILL.md:209`, `:222`의 예산·하위 잡 라벨 반영은 확인했다.
|
||||
3. **테스트 이식성/격리.** stall watcher 테스트 `:39`의 모듈 전체 Linux skip은 순수 judge·pane parser 테스트까지 숨긴다. /proc·Linux 프로세스 사실을 쓰는 사례만 별도로 표시하는 편이 좋다. 기존 전달 테스트 `test_issue18_liveness_contract.py:497`은 unreachable 환경을 만들기 전에 잡을 등록한다. 잡의 저장된 broker가 환경을 덮어쓰고(`mqtt_common.py:294`), `:511`의 thread join 시간까지 측정하므로 외부 브로커 지연에 따라 시간 assertion이 실패한다. 등록 단계부터 브로커를 격리해야 한다.
|
||||
|
||||
## 코드 계약 확인
|
||||
|
||||
- `--timeout > MAM_JOB_TIMEOUT_S > 3600`, loop `--job-timeout`, 공통 `contract_args()` 전달 경로와 예산+grace(기본 300초) 대기 상한을 확인했다. 구현·리뷰·수정·재심·중재·순응은 공통 delegate 경로를 사용한다. 명시적 `MAM_CHILD_WAIT_S`는 유지된다. remote dispatch는 `timeout_sec`을 운반하고 두 remote 만료 문구 모두 새 라벨을 쓴다.
|
||||
- subscriber의 rc 2/4와 bash 상수 일치, `delegate-job wait`의 rc 2 호환, broker-down 재관측 및 disk-heartbeat 유예가 wall deadline에 닿는 라벨을 검토했다. registry close/engine의 새 request 및 하위 잡 `CLOSED-UNCONFIRMED` 분류 테스트가 통과했다. 별도 순수 전이 탐침에서도 `wall_clock_timeout → error` 후 늦은 completed는 `conflicting completed after terminal error; refusing`으로 거부됐다.
|
||||
- stall watcher의 단일 발행 경로는 `progress`뿐이다. close/error/cancelled 발행이나 자동 승인 경로가 없고, AST 테스트도 이를 확인한다. blocked는 detection 문구를 붙이고 답하지 않는다. idle은 연속 idle 기간, seq 재확인, 어댑터 신뢰도, PID/자손, 빈 입력, 1회 제한을 조합한다. 정상 local watcher 수명, human/remote 제외, `MAM_STALL_WATCH=0`, herdr 장애 경고/종료, loop의 실시간 알림 relay는 모의 환경에서 확인했다. 이 긍정적 결과가 R1·R2의 예외 경로를 보장하지는 않는다.
|
||||
- 기존 G-8에서 wall=idle=0.5였던 경합을 wall=0.5/idle=30으로 바꾼 것은 타당하다. rc 이름 상수와 CLI help snapshot, issue18 contract_args 기대값 수정도 새 인터페이스에 부합한다. conftest의 process-info shape·detection·seq·wait 지연은 모의 객체에 국한된다.
|
||||
- 5종 idle pane은 herdr 0.9.1, 2026-09-25 캡처 메타데이터를 가진다. typing 변형은 실측이 아닌 합성이라는 점이 테스트에 명시돼 있다. 실측된 빈 입력과 합성 비어 있지 않은 입력은 모두 통과했다. 실제 Claude 시험 A/B 자체는 이번 브리프의 세션 조작 금지 때문에 재실행하지 않았다. 모의 blocked/idle/자식/입력 조건 검증을 실제 에이전트 실험의 독립 재현으로 부풀리지 않는다.
|
||||
|
||||
## bash/Python 경계 표
|
||||
|
||||
| 위치 | 새로 수행하는 일 | 경계 판단 / 후속 이전 대상 |
|
||||
| --- | --- | --- |
|
||||
| 래퍼 `:146` | env 양의 정수 검증·기본값 선택 | 정책 결정이다. Python 공통 예산 파서로 이전. |
|
||||
| 래퍼 `:832` | subscriber rc를 job 상태 의미로 매핑 | 프로세스 rc 수집은 bash, 상태/실패 의미 결정은 Python. |
|
||||
| 래퍼 `:902`, `:950` | 현재 시각과 deadline 비교 후 FINALIZE_CLOCK 선택 | 단순 전달이 아니라 어느 시계가 끝났는지 결정한다. #17 항목 4 대상. |
|
||||
| 래퍼 `:956` | rc/clock/phase에 따라 close request·사유 선택 | 종결 정책이다. Python이 구조화된 결과를 반환하도록 이전. |
|
||||
| 래퍼 `:687` | env switch·remote·human 감시 제외 | 현재는 bash에 있는 정책. 장기적으로 Python의 감시 가능 여부 결정과 합칠 수 있다. |
|
||||
| 래퍼 `:700`, `:711` | 프로세스 시작·kill·wait | bash 책임에 맞는다. R1 수명 정리는 여기서 보강. |
|
||||
| 래퍼 `:1214` | 외부 wait rc 4를 호환 rc 2로 변환 | 얇은 CLI 경계 변환으로 유지 가능하나 상수/계약은 Python과 일치해야 한다. |
|
||||
|
||||
따라서 “bash는 라벨을 나르기만 한다”는 주장은 정확하지 않다. 다만 #17 항목 4 이전이 이미 후속으로 합의됐으므로 이 경계상 부채 자체는 비차단이다.
|
||||
|
||||
## 실행 검증 결과
|
||||
|
||||
인터프리터는 브리프의 `.venv/bin/python`(macOS Python 3.9.6)을 사용했다. pytest는 `AGENT_PYTHON_BIN` 지정, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖의 `--basetemp=/tmp/0f12973f-*`로 실행했다.
|
||||
|
||||
| 실행 | 결과 |
|
||||
| --- | --- |
|
||||
| issue33, issue30, issue18, a4 adapter, tier1, issue5 phase1 — 원본 선택 265건 | 214 passed / 3 failed / 48 skipped, 342.12초 |
|
||||
| 위 실패 3건만 재실행 | 2 passed / 전달 timing 1 failed, 49.73초 |
|
||||
| 전달 timing: 잡 등록부터 MQTT=127.0.0.1:9로 격리 | 1 passed, 2.36초 |
|
||||
| issue30: 외부 pytest plugin으로 모듈 Linux skip을 선택적으로 해제 | 43 passed / 2 failed / 3 skipped, 306.06초 |
|
||||
| 위 R4 2건: 임시 herdr 모의 스크립트의 프로세스 간 계측만 보정 | 2 passed, 125.18초 |
|
||||
| base parser 임시 사본을 현재 S14에 적용 | Codex/Grok/OpenCode 3건 예상 실패; agy/claude는 기존에도 통과 |
|
||||
| 같은 S14에 head parser 적용 | 5건 통과 |
|
||||
| 정적 검사 | `git diff --check`, 두 bash 진입점 `bash -n` 통과 |
|
||||
|
||||
첫 표준 실패 중 2건은 브로커 subscribe/heartbeat 타이밍이었고 재실행에서 통과했다. 나머지 전달 테스트는 위에 설명한 저장된 broker와 thread join의 영향을 분리해 통과를 확인했다. 보조 base archive 실행도 통과했으나 브로커 변동이 있으므로 이것만으로 코드 회귀라고 판정하지 않았다.
|
||||
|
||||
강제로 활성화한 R4 2건의 최초 실패 값은 연속 idle 시간이 각각 `-0.00066`, `-0.00741`초였다. Python 3.9.6/macOS의 각 모의 herdr 프로세스에서 monotonic 기준점이 달라지는 계측 문제다. 임시 모의 스크립트에서만 cross-process timestamp를 `time.time()`으로 바꾸고 생산 감시자 코드는 그대로 두자 둘 다 통과했다. 원본 Linux suite가 macOS에서 그대로 통과했다고 주장하지 않는다. Linux process-name 2건과 /proc heartbeat 1건은 검증하지 않았다.
|
||||
|
||||
종합하면 선택한 265개 사례 중 262개는 위 재실행/이식 조건에서 통과를 확인했고 3개 Linux 전용 사례는 미검증이다. 별도 R1·R2 탐침은 제품 결함을 재현했다. 테스트 수만으로 PASS를 내릴 수 없다. 검사와 진단용 임시 plugin은 `/tmp/0f12973f-tools/`에 있고 검증 트리는 수정하지 않았다.
|
||||
|
||||
## 설계 합의
|
||||
|
||||
**A — 동의.** #30은 stalled 판단으로 잡을 닫거나 blocked 승인을 자동 수락해서는 안 된다. 이 PR은 `progress` 단일 발행 경로와 close 경로 부재로 그 원칙을 구현한다. idle의 1회 계속 요청도 합의 범위에 맞는다. 다만 알림 전용이어도 prompt는 외부 부작용이므로 R1·R2의 수명/dispatch 경계를 지켜야 한다. 이번 NOT PASS는 그 실행 안전성 결함이며 알림 전용 원칙에 대한 반대가 아니다.
|
||||
|
||||
**B — 동의.** 연장을 #39의 명시적인 프로토콜 요청으로 분리하는 것이 타당하다. 동기 래퍼 호출 중인 러너의 관측 정체를 작업 진행 여부나 연장 승인으로 해석하기 어렵고, seq 정체도 정상 장기 작업에서 생긴다. 요청은 승인이 아니어야 한다. 오케스트레이터가 잡별 누적 최대 예산/횟수를 두고 token·attempt로 인증된 요청을 멱등 처리해 새 절대 deadline을 확정·배포하도록 권한다. 이 규칙을 remote에도 동일하게 적용하면 무한 연장과 양쪽 deadline 불일치를 줄일 수 있다.
|
||||
|
||||
**C — 동의.** #40의 트리거는 completed 수신 측이어야 한다. 에이전트 publish_event에 YAML/SQLite 제어 평면 쓰기를 넣으면 워크트리 creator와 `MAM_NO_SUBSCRIBER_DAEMON=1`에서 소유권·경로 문제가 다시 생긴다. 공통 Python 완료 수신 처리기를 두고 래퍼 구독/디스크 완료 관측을 기본 진입점으로 삼는 쪽이 standalone 위임까지 포괄한다. loop `wait_for_job` 직후는 누락 복구 경로로 같은 처리기를 호출할 수 있으며 job/token/attempt 단위로 중복 capture/pin을 막아야 한다. 어댑터는 id_lifecycle·capture·pin 사실/동작을 제공하고, 실행 시점과 제어 평면 쓰기의 권한은 수신 측이 갖는다. #17 항목 4에 흡수하거나 바로 다음 순서가 적절하다.
|
||||
|
||||
**D — 조건부 동의.** 이번 PR에서 종결 처리 전체 이전까지 요구하지 않는 것은 범위를 제한한다는 점에서 타당하다. 다만 위 표처럼 bash에 새 정책이 들어간 사실을 인정하고 후속 작업으로 추적해야 한다. #17 항목 4에서 예산 정규화, rc→상태 해석, deadline/idle 판정, FINALIZE_CLOCK와 phase별 close request·사유, 디스크 유예/재관측 종료 결정을 Python으로 옮긴다. bash에는 spawn/wait/kill, 신호 정리 및 얇은 외부 CLI 호환 처리를 남긴다. 이 분리와 별개로 R1·R2는 이번 병합 전에 수정해야 한다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261023T035928Z-2ccc02",
|
||||
"base_sha": "dc886ecd6b38c01032a55a2d1c230c73362ae630",
|
||||
"head_sha": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"tree_sha": "f1af6fdd548ee5d6f6b81e2202833edfaca98188",
|
||||
"review_commit": "1614af5b673d7519d3a38f63e3636055592b4f14",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "12669b52",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-23T05:16:58Z"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Reviewer report — PR #69 / Issue #63
|
||||
|
||||
Reviewed the required snapshot at `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261023T035928Z-2ccc02/1`, head `1614af5b673d7519d3a38f63e3636055592b4f14`, against base `dc886ecd6b38c01032a55a2d1c230c73362ae630`. The snapshot matches the expected tree `f1af6fdd548ee5d6f6b81e2202833edfaca98188` and is clean. I read Issue #63 and PR #69. The latest delta is limited to `id_order.py` and its regression tests.
|
||||
|
||||
## Previously blocking finding is closed
|
||||
|
||||
At the previous head (`7cac6f6`), `_read_and_prune_markers` dropped a dead-owner marker when its session name matched the caller, without checking herdr. The new code removes that exception: `.agents/skills/lib_py/agents/id_order.py:219-230` now keeps a dead-owner marker until its row is registered for that attempt or herdr returns an authoritative `False`; live and unknown answers both retain the orphan. `blockers()` includes a marker without a row even when its name is the caller's (`id_order.py:145-148`), so a retry is refused rather than replacing its marker.
|
||||
|
||||
The tests now cover same-name retries with a live owner, a dead owner and live herdr session, unknown herdr state, and a confirmed-gone session (`tests/test_issue63_same_cwd_ids.py:1117-1148`). The real-probe integration test also retries using the orphan's own name and checks that the marker is retained (`tests/test_issue63_same_cwd_ids.py:1287-1290`) and then safely replaced once the session is confirmed gone (`1312-1315`). The test fixture uses a mocked herdr and in-process probes; it does not contact a real herdr server.
|
||||
|
||||
## Verification
|
||||
|
||||
- `PYTHONDONTWRITEBYTECODE=1 .../python -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-12669b52 tests/test_issue63_same_cwd_ids.py` — **27 passed** on macOS, Python 3.9.6 (230.59 s). This includes the orphan liveness, unknown-result, zero-wait-cap, and same-name retry cases.
|
||||
- Changed shell scripts checked with `/bin/bash -n` — passed.
|
||||
- `git diff --check dc886ecd6b38c01032a55a2d1c230c73362ae630..HEAD` — passed.
|
||||
- The earlier review covered the broader unchanged stop/purge/resume implementation and adjacent compatibility checks. This re-review did not repeat the base checkout or all adjacent test groups because the new delta is confined to dead-owner marker pruning and its regression tests.
|
||||
- No real agent-home databases or real herdr sessions were accessed.
|
||||
|
||||
The latest change fixes the remaining blocking same-name orphan path. The earlier probe-failure and wait-cap fixes remain covered by the passing Issue #63 tests. I found no blocking issue in this revision.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,91 @@
|
||||
# PR #41 수정분 델타 리뷰 — 1b4efe14
|
||||
|
||||
**NOT PASS.** N1의 TERM 무시 감시자에 대한 무한 wait는 해결됐고, 새 테스트가 다루는 R1/R2 상황도 통과했다. 다만 블로킹 알림 발행 뒤에는 dispatch를 재확인하지 않아 완료·재디스패치 후 prompt가 여전히 전송된다. 부모 PID를 대기 직후에 확인하지 않는 경로도 남았다. 아래 두 잔여 결함을 작은 후속 수정으로 해결할 수 있으므로 재계획 에스컬레이션은 필요하지 않다.
|
||||
|
||||
## 검증 identity
|
||||
|
||||
- base: `1673818bb5fed14ed6fee87884e70d39e81fe91f`
|
||||
- head: `af0a2a2557768fa316203b177e1b90e9f7283a1a`
|
||||
- tree: `0310682605711b1dc7e31cda84ed6697d8ba6ce8`
|
||||
- loop: `loop-20260926T052018Z-597343`
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260926T052018Z-597343/1`
|
||||
|
||||
이 경로에서 5개 파일의 delta(+406/−12)를 검토했다. 소스·본 체크아웃을 수정하거나 실제 herdr/에이전트 세션을 조작하지 않았다. 테스트와 탐침은 임시 레지스트리, HOME, 모의 herdr 및 소유 프로세스만 사용했다. 아래 파일:라인은 head 기준이다.
|
||||
|
||||
## 남은 차단 결함
|
||||
|
||||
### D1 · P2 · R2 미완료 — 알림 발행 중 완료/재디스패치되면 이전 dispatch의 prompt를 보낸다
|
||||
|
||||
**근거:** `.agents/skills/lib_py/stall_watch.py:384`에서 `_dispatch_open()`을 확인하지만, `:390`의 `_notify()`가 돌아온 뒤 `:393`은 바로 prompt를 보낸다. `_notify` 내부 `:246`은 별도 발행 프로세스를 최대 120초 기다리는 블로킹 호출이다. 따라서 이것은 마지막 검사와 prompt 사이의 미세한 명령어 경합만이 아니라 네트워크 대기 구간이다.
|
||||
|
||||
발행 rc 0은 “현재 dispatch가 아직 열려 있음”을 보장하지 않는다. `.agents/skills/lib_py/delegation/publish_event.py:461`은 wire 발행 전에 progress를 로컬에 기록하고, `:486`의 상태 동기화는 best-effort다. `:508`~`:513`의 반환값은 wire 성공/실패에 따라 정해진다. 발행 도중 completed가 기록되거나 새 attempt가 시작돼도 rc 0이 가능하다. rc 2도 감시자에서는 로컬 기록 성공으로 받아들이므로, 실패 브로커도 최종 dispatch 검사 대신 사용할 수 없다.
|
||||
|
||||
**재현 절차:** 임시 UUID4 잡을 running으로 등록하고 실제 `StallWatch._act()`에 idle/seq 일치/자식 없음/빈 입력 Source를 준다. `_notify` 자체는 실제 코드를 사용한다. 그 subprocess 실행 경계만 실제 `publish_event.main()` 호출로 연결하고, publisher의 wire 함수에서 다른 actor의 completed 전이를 끼워 넣은 뒤 성공을 반환한다. 재디스패치 변형에서는 wire 대기 중 completed 이후 임시 레코드를 새 token·attempt 2/running으로 교체한다. 실제 네트워크·세션은 사용하지 않는다.
|
||||
|
||||
**기대:** 발행이 끝난 뒤 최신 job/token/attempt를 재확인하여 완료 또는 다른 dispatch면 prompt를 보내지 않는다.
|
||||
|
||||
**실제:** completed 변형은 `publisher_rc=[0]`, `record={status: completed, attempt_id: 1}`인데 prompt 1회가 기록됐다. 재디스패치 변형도 `publisher_rc=[0]`, `record={status: running, attempt_id: 2}`인데 이전 잡/attempt의 prompt 1회를 기록했다. 이 탐침은 `_notify=True`만 반환하는 대역이 아니라 실제 발행 로직의 rc를 사용한다.
|
||||
|
||||
**같은 경계의 일시 오류 문제:** `_dispatch_open()`의 `:276`~`:277`은 OSError/ValueError를 `True`로 바꾼다. 별도 탐침에서 완료된 임시 레코드의 최종 검사에만 ValueError 1회를 주고 이후 읽기는 정상으로 두자, 실제 publisher가 progress를 rc 0으로 발행하고 prompt가 completed 상태에 전송됐다. 관측 루프를 계속하는 것과 prompt를 허가하는 것은 분리해야 한다.
|
||||
|
||||
**수정 방향:** `_notify` 반환 후 실제 prompt 직전에 job/token/attempt를 다시 읽어 확인한다. 읽기 실패는 감시자 전체를 즉시 죽일 필요는 없지만 해당 회차의 재주입은 보류한다. 발행 rc 0/2를 상태 확인의 대체재로 삼지 않는다. wire 발행 중 completed/redispatch, 마지막 읽기의 일시 오류에 대한 테스트를 추가한다. 긴 RPC 동안 registry lock을 잡을 필요는 없다.
|
||||
|
||||
### D2 · P2 · R1 잔여 경로 — 부모가 대기 중 사라져도 한 번 더 알림·재주입한다
|
||||
|
||||
**근거:** `.agents/skills/lib_py/stall_watch.py:298`은 루프 머리에서만 부모 PID를 확인한다. `:315`의 `wait_once` 뒤 `:332`는 dispatch만 검사하고 부모를 검사하지 않는다. 이어 `_act`의 collect/notify/prompt가 실행된다. transport error 분기 `:316`~`:328` 역시 부모 소멸을 다시 확인하기 전에 경고 발행과 backoff를 수행한다.
|
||||
|
||||
**재현 절차:** 실제 `StallWatch.run()`에 모의 wait/Source와 시계를 준다. 첫 wait에서 idle을 관측하고 두 번째 wait 동안 부모가 사라진 것으로 바꾸되 잡은 running으로 유지한다. TIMEOUT 반환 시 idle 임계값을 넘고 입력은 비어 있으며 자식은 없다. `_alive`는 이후 False를 반환한다.
|
||||
|
||||
**기대:** wait가 끝나면 부모 소멸을 확인해 rc 0으로 종료하고 새 알림·prompt를 보내지 않는다.
|
||||
|
||||
**실제:** `parent_alive=False`, `notices=['stalled:idle']`, `prompts=1` 뒤에야 다음 루프 머리에서 rc 0으로 종료했다. 무기한 고아 문제는 일반 경로에서 해결됐지만, 제어 주체가 없어진 뒤의 부작용은 남아 있다. 또한 `_notify`의 120초 대기와 다른 herdr 호출이 추가될 수 있어 `:300`~`:301`의 “최대 한 번의 wait만 지연”이라는 설명도 현재 제어 흐름 전체에 대해서는 성립하지 않는다.
|
||||
|
||||
**수정 방향:** wait에서 돌아온 직후, 오류 알림 전에 부모를 검사하고, collect/발행 같은 블로킹 작업 뒤 prompt 직전에도 검사한다. 부모 소멸 시 잡을 닫지 않고 조용히 종료하는 기존 원칙은 유지한다. 새 R1 테스트의 `MAM_STALL_IDLE_S=600`은 종료만 검증하므로, 부모 소멸과 idle 임계값 도달이 겹치는 사례도 추가한다.
|
||||
|
||||
실행 가능한 D1(완료/재디스패치)·D2 탐침: `/tmp/1b4efe14-tools/probe_notify_gap.py`. 검증 cwd에서 브리프의 Python으로 실행하고 위 검증 경로를 인자로 전달한다.
|
||||
|
||||
## 요청 항목별 판단
|
||||
|
||||
**N1 종료 대기 — 해결.** 래퍼 `:710`~`:731`의 TERM→25×0.2초 폴링→KILL→wait는 TERM을 무시하는 정상 OS 자식의 무한 wait를 제거한다. 5초는 TERM 유예 시간이며 fork/scheduling/회수까지 정확히 5.000초 이하라는 뜻은 아니다. KILL 뒤 자신의 자식을 wait하는 부분에서 이번 결함과 같은 TERM 무시 경로는 남지 않는다. 빈 STALL_PID는 바로 반환하고, 이미 끝난 rc 7 자식은 오류를 보고한 뒤 반환하는 것을 별도로 확인했다. 커널 자체가 회수 불가능한 프로세스 같은 일반 OS 제한을 이번 PR의 새 차단 사유로 삼지 않았다.
|
||||
|
||||
**watch-pid 방식 — 조건부 수용.** trap 합성 자체가 필수는 아니다. 래퍼가 양의 실제 `$$`를 넘기고 Python이 소멸을 관측하는 방법은 SIGKILL에도 대응할 수 있다. `_alive`의 PermissionError→True, ProcessLookupError→False를 확인했다. 단, 정상 herdr CLI가 timeout 안에 반환한다는 가정이 있으며 `watcher.py:165`에는 독립 subprocess timeout이 없다. 이번 수정의 문제는 D2의 관측 위치다. “TERM에서도 trap을 전혀 실행할 수 없다”는 설명은 정확하지 않지만, trap 부재 자체로 NOT PASS를 내지는 않는다. 문서의 “exactly as long”은 검사 지연을 반영해 완화하는 편이 정확하다.
|
||||
|
||||
**dispatch 검사와 blocked 알림 — 부분 수용.** 없는 레코드를 gone으로 보는 것은 맞다. 일시 오류 때 관측을 계속하는 것도 가능하지만 `True`로 prompt까지 허용하는 것은 D1에서 고쳐야 한다. token/attempt 불일치 검사는 정상 레코드에서 유효하다. blocked는 자동 응답 없이 progress만 보내므로, detection 조회 중 상태가 바뀌어 뒤늦은 진단 1회가 남는 것 자체는 이번 재주입 결함과 같은 차단 사유로 보지 않는다. 그래도 부모 소멸 후 추가 발행은 D2의 공통 검사로 막는 편이 좋다.
|
||||
|
||||
**새 테스트 — 유효하지만 경계가 좁음.** stop 함수 자체의 TERM 무시 대역과 실제 래퍼/shim 경로를 함께 검증하는 접근은 좋다. 부모 소멸과 wait 중 완료/redispatch, 발행 거부의 7건은 현재 코드에서 통과했다. 반면 R2 테스트는 `_notify`를 즉시 True/False를 반환하는 메모리 대역으로 바꾸므로 실제 wire 대기 중 상태 변경을 검증하지 않는다. 정상 테스트 경로의 프로세스 정리는 확인했지만 실패 경로 정리는 보완할 만하다. 예를 들어 `tests/test_issue30_stall_watch_stop.py:162`의 assert가 실패하면 `:163`의 stand_in.kill에 도달하지 않으며 finally는 watcher만 죽여 sleep 300이 남을 수 있다. 이 점은 테스트 유지보수 비차단 사항이다.
|
||||
|
||||
**M8 — 해결.** /proc에 의존하는 S6만 skip으로 좁히고 R4 모의 프로세스 사이의 timestamp 비교를 time.time으로 바꾼 것은 이 macOS/Python 3.9 환경에 맞다. 생산 감시자의 연속 idle 판정은 여전히 단일 프로세스 monotonic 시계를 쓰므로 바뀌지 않는다. S5 상주 자식 두 사례도 이번에는 원본 테스트로 실행됐다. 주석은 모든 macOS/Python 버전으로 일반화하기보다 지원 환경에서의 프로세스 간 계측 문제로 설명하는 것이 더 정확하다.
|
||||
|
||||
## 1회차 비차단 항목 처리표
|
||||
|
||||
| 1회차 항목 | 이번 상태 | 판단 |
|
||||
| --- | --- | --- |
|
||||
| 선행 0 예산 `MAM_JOB_TIMEOUT_S=08`의 bash/Python 불일치 | 미반영 | #17 항목 4의 공통 정수 파서로 넘길 기존 비차단 부채. |
|
||||
| delegate SKILL의 idle/total-budget/broker 라벨 및 v4.7.5 문구 | 닫힘 | 새 표가 idle과 wall clock을 구분하고 v4.8.0으로 정정됐다. |
|
||||
| stall 테스트 모듈 전체 Linux skip | 닫힘 | S6만 skip. 순수·모의/프로세스 테스트가 macOS에서도 실행된다. |
|
||||
| R4 모의 프로세스 사이 monotonic 기준점 문제 | 닫힘 | 모의 timestamp만 교체됐다. |
|
||||
| 기존 issue18 전달 테스트의 등록 broker/네트워크 timing 의존 | 미반영 | 이번 delta에 해당 fixture 수정 없음. B3 공개 브로커 지연(M9)과 함께 별도 격리 개선 대상. 이번 판정의 차단 근거 아님. |
|
||||
| bash의 예산 검증·FINALIZE_CLOCK·종결 정책 | 후속 유지 | #17 항목 4 이전 대상. 이번 TERM/KILL/wait는 외부 프로세스 관리라 bash 책임에 맞는다. |
|
||||
|
||||
## 검증 결과
|
||||
|
||||
지정된 네 모듈을 검증 경로에서 브리프의 worktree `.venv/bin/python`과 `AGENT_PYTHON_BIN`으로 실행했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 외부 `--basetemp=/tmp/1b4efe14-suite`를 사용했다.
|
||||
|
||||
| 실행 | 결과 |
|
||||
| --- | --- |
|
||||
| `test_issue30_stall_watch_stop.py`, `test_issue30_stall_watch.py`, `test_issue14_delivery_ack.py`, `test_tier1_unit.py` — 134건 | 132 passed / 1 failed / 1 skipped, 590.08초 |
|
||||
| 실패한 `test_wrapper_idle_timeout_grace_ignores_pane_busy_token`만 별도 basetemp에서 재실행 | 1 passed, 27.69초 |
|
||||
|
||||
최초 실패는 `tests/test_issue14_delivery_ack.py:350`에서 started 발행이 브로커 CONNACK을 세 차례 받지 못해 rc 2를 반환한 경우다. 재실행에서 통과했으며 제품 결함 판정에 포함하지 않았다. 선택한 사례 기준 133건의 통과를 확인했고, S6(/proc) 1건은 이 macOS 환경에서 미검증이다. 새 stop 7건, 기존 stall 47건은 원본 코드/테스트 그대로 통과했다. 전체 실행과 재실행 후 이번 임시 경로를 사용하는 감시자·heartbeat 프로세스가 남지 않았는지 확인했다.
|
||||
|
||||
추가 검증은 다음과 같다.
|
||||
|
||||
- 임시 base 모듈로 새 R2 테스트 3건 실행: 완료 중 wait, 재디스패치 중 wait, 발행 거부 모두 예상한 AssertionError로 실패. head의 해당 3건은 원본 suite에서 통과.
|
||||
- base의 stop 함수를 임시 프로세스 그룹에서 실행: TERM 무시 자식을 기다리며 8초 후에도 반환하지 않음. 탐침 그룹을 KILL하고 회수했다. head의 상한 테스트는 원본 suite에서 통과.
|
||||
- head의 empty PID/이미 종료 rc 7, `_alive`의 PermissionError/ProcessLookupError 4개 추가 확인 통과.
|
||||
- 실제 publisher 로직을 통과하는 완료/재디스패치 중 알림 탐침 2건, 부모 소멸 중 wait 탐침 1건, 일시 읽기 오류 뒤 completed prompt 탐침 1건은 위 결함을 재현했다. 실제 MQTT나 herdr 세션에 발행하지 않았다.
|
||||
- `git diff --check 1673818`, 래퍼 `bash -n` 통과. 검증 트리 HEAD/tree 및 깨끗한 상태를 재확인했다.
|
||||
|
||||
이전 설계 합의(알림 전용, #39 분리, completed 수신 측 #40 트리거, #17 항목 4 후속)는 유지한다. 이번 NOT PASS는 설계 의견이나 미반영 비차단 사항이 아니라 재현된 D1·D2에 근거한다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,56 @@
|
||||
# PR #51 round 2 delta review — Codex
|
||||
|
||||
Job: 24b21b9c
|
||||
Loop: loop-20261007T162612Z-9fe4f9
|
||||
Base: 518bedac5d8ab832347bfc6682b146f3201021f1
|
||||
Head: 4c86107846d1e037f9a69a1201257725e8673179
|
||||
Tree: e6f67f6136b8f0cec15ce98400ae7d7710cedf1e
|
||||
Validation path: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261007T162612Z-9fe4f9/1
|
||||
|
||||
## Conclusion
|
||||
|
||||
R1 is closed on code inspection and deterministic execution. No new code defect was found in the two-file delta. Overall approval is withheld solely because the required two-file suite could not execute its test bodies under this session's network restrictions. This is a validation/environment blocker, not a request to change the implementation. Run the required suite in a permitted isolated environment before promoting this review to PASS; no design escalation is needed.
|
||||
|
||||
## R1 closure table
|
||||
|
||||
| Item | Evidence | Result |
|
||||
| --- | --- | --- |
|
||||
| 1-second wait after a 10-second backward wall-clock step | wrapper.py:150,153; independent base/head comparison: 11.0 seconds -> 1.0 second | Closed |
|
||||
| 3-second wait after the same step | wrapper.py:150,153; independent base/head comparison: 13.0 seconds -> 3.0 seconds | Closed |
|
||||
| Broker re-observation consumes the corrected wait | wrapper.py:211; finalize_end with completion appearing during the wait: base 13.0 seconds, head 3.0 seconds, both return completed | Closed |
|
||||
| Heartbeat grace consumes the corrected wait | wrapper.py:239; finalize_end with completion appearing during the wait: base 11.0 seconds, head 1.0 second, both return completed | Closed |
|
||||
| Owner polling retained | wrapper.py:152,156; injected owner loss observes checks at elapsed 0.0, 0.5, 1.0 seconds and raises OwnerGone at 1.0 | Preserved |
|
||||
| Deadline retains wall-clock policy | wrapper.py:203,226,240; forward wall-clock jump during the 1-second grace produces wall_clock_timeout at elapsed 1.0 second | Preserved |
|
||||
|
||||
File references above are relative to `.agents/skills/lib_py/delegation/` in the validation tree.
|
||||
|
||||
## Scope and test quality
|
||||
|
||||
Only `.agents/skills/lib_py/delegation/wrapper.py` and `tests/test_issue17_item4_decisions.py` changed (43 insertions, 3 deletions). Production changes are restricted to the `_sleep` docstring and its two clock reads. `_now`, heartbeat timestamp age, evidence windows, START_TIME + TIMEOUT, subprocess/registry/event handling, and surrounding branch logic are unchanged.
|
||||
|
||||
The regression tests at tests/test_issue17_item4_decisions.py:598-630 use separate simulated wall and monotonic clocks, step wall time backward once, and advance in deterministic half-second increments. They perform no real sleep or clock adjustment. Both 1- and 3-second cases distinguish the faulty base from the fixed head decisively. The extra owner/deadline probes cover behavior not asserted by these two new tests.
|
||||
|
||||
Previous nonblocking follow-ups and the unrelated main-branch restore failure were not reopened.
|
||||
|
||||
## Validation results
|
||||
|
||||
- Required suite attempted with Python 3.9 from `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-51/.venv/bin/python`, PYTHONDONTWRITEBYTECODE=1, pytest cache disabled, and external basetemp `/tmp/24b21b9c-selected`.
|
||||
- Targets: `tests/test_issue17_item4_decisions.py tests/test_issue33_budget.py`.
|
||||
- Result: **278 setup errors in 1.80s**, exit 1; no passing test-body claim is made for this run. Every case is blocked by the session-autouse local MQTT fixture: tests/conftest.py:1575 calls start_broker, then tests/_local_mqtt.py:90 fails at `self._lsock.bind(("127.0.0.1", 0))` with `PermissionError: [Errno 1] Operation not permitted`.
|
||||
- Required-suite log: `/tmp/24b21b9c-selected.log`.
|
||||
- Separate network-free run: `python -m pytest --noconftest -p no:cacheprovider -q --basetemp=/tmp/24b21b9c-unit-tests tests/test_issue17_item4_decisions.py::test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back` -> **2 passed in 0.08s**. This deliberately excludes the broker autouse fixture and is supplementary evidence, not a substitute claim for the required suite.
|
||||
- Independent probe script `/tmp/24b21b9c-probes.py`: all assertions passed for base/head sleep, both finalize paths, forward wall-clock expiry, and owner polling. Actual system time was never changed; side effects and disk reads were stubbed for finalize probes.
|
||||
- Both changed Python files passed AST parsing; `git diff --check BASE HEAD` passed.
|
||||
- HEAD/tree matched the brief before and after validation; final `git status --porcelain` was empty.
|
||||
|
||||
## Reproduction and resolution of the validation blocker
|
||||
|
||||
Use the brief's required two-file pytest command in this restricted session. Expected: the isolated local broker starts and the 278 test bodies execute. Actual: binding the local TCP listener is denied before any test body runs. Resolution: rerun the same suite in an environment that permits its isolated loopback broker; do not modify product code, suppress the integration fixture, or classify setup errors as passing tests.
|
||||
|
||||
## Isolation and lifecycle
|
||||
|
||||
Before tests and probes, inherited HERDR_*, MAM_*, MQTT_*, SKS_* and control-plane binding variables were removed; temporary HOME, HOME_DIR, WORKSPACE_ROOT, XDG_CONFIG_HOME and absent MAM_ENV_FILE were supplied. The required-suite harness additionally set WORKSPACE_ROOT to each test's tmp_path via an environment-only autouse plugin, so sourcing helpers cannot generate a shim in the review snapshot or another workspace. No .mam.env was exported, no live herdr server was used, and no source files, branches or commits were changed. Only the requested report and lifecycle publisher used this job's real control-plane paths.
|
||||
|
||||
The requested started command was attempted first, but MQTT publication exhausted three attempts and exited 2 with Operation not permitted. The requested completed command was also executed after saving this report; all three MQTT attempts failed with Operation not permitted and the command exited 2. The report is saved, but neither lifecycle event was successfully delivered to the broker.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261015T123502Z-1b9df6",
|
||||
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
|
||||
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
|
||||
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
|
||||
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "2e06828a",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-15T13:18:35Z"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# PR #58 Mac 2차 리뷰 — PASS
|
||||
|
||||
Job `2e06828a`, Reviewer. 이전 네 지적 사항(F1/F2/R1/R2)이 해결됐음을 확인했다. 관련 테스트 83건이 통과했으며, 수정 직전 커밋에서는 해당 회귀 테스트 6건이 각각 예상한 이유로 실패했다. 이번 검토 범위에서 새 차단 이슈는 발견하지 못했다. 소스 수정은 하지 않았다.
|
||||
|
||||
## 검증 identity
|
||||
|
||||
- loop: `loop-20261015T123502Z-1b9df6`
|
||||
- base: `e32c9574644a9185813a4dc881812694d62b6204`
|
||||
- head: `a7cb90e63abf454ccf22f6ff887cc25393755402`
|
||||
- tree: `96c5c5f82cb89e4383011d52fb1dcb21d4e787e6`
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1`
|
||||
- 누적 범위: base..head 15 commits / 23 files. 이전 검토 head `63d8164` 이후 4개 수정 커밋을 집중 검토하고 누적 변경과의 정합성을 대조했다.
|
||||
- 실제 환경: macOS, Python 3.9.6, GNU Bash 3.2.57(arm64-apple-darwin25).
|
||||
|
||||
## 지적 사항별 확인
|
||||
|
||||
| 항목 | 수정 전 검증 | 현재 head 검증 | 판정 |
|
||||
|---|---|---|---|
|
||||
| F1: paho 없는 Python 경로 계산 | `63d8164`에서 binding 1건 및 status json/text 2건이 ModuleNotFoundError로 실패 | 같은 3건 통과. 별도로 실제 `/usr/bin/python3`의 paho spec이 None임을 확인하고 `mam_control_plane_json` 및 status의 두 `get_job_status` 함수를 실행해 성공 | 해결 |
|
||||
| F2: 다른 cwd의 기본 logs | `75c0042`에서 WORKSPACE_ROOT의 잡 대신 elsewhere/.mam 로그 루트를 조회하여 실패 | WORKSPACE_ROOT=ws, cwd=elsewhere에서 `logs --list`와 `logs <id>` 모두 잡/registered 이벤트 조회 성공 | 해결 |
|
||||
| R1: 다른 dispatch 완료 오인 | `151af99`에서 A error → redispatch → B completed interleaving이 A를 completed로 종료시켜 실패 | 동일 interleaving에서 A는 pending 유지, terminal 비어 있음. B 토큰으로 다음 조회 시 completed. 별도 탐침도 동일 결과 | 해결 |
|
||||
| R2: registry 자체의 symlink | `1c598bb`에서 alias/jobs의 sibling이 actual이 아닌 alias로 계산되어 실패 | alias/jobs → actual/jobs에 submit --dry-run 후 같은 alias 인수로 logs 조회 성공. alias 옆에는 로그를 생성하지 않음 | 해결 |
|
||||
|
||||
수정 전 테스트는 검증 저장소에서 `git archive <각 수정 커밋의 부모>`로 만든 임시 복사본에서 실행했다. 해당 복사본에는 현재 head의 `tests/test_issue57_logs_follow_registry.py`만 복사해 새 회귀 테스트를 실행했다. 실제 코드의 이전 동작 때문에 실패했으며 수집 오류나 환경 오류로 실패한 것이 아니다. 원본 검증 트리나 creator 브랜치는 변경하지 않았다.
|
||||
|
||||
각 회귀 테스트:
|
||||
|
||||
- F1: `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json]`, `test_status_works_without_paho[text]`.
|
||||
- F2: `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd`.
|
||||
- R1: `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch`.
|
||||
- R2: `test_a_symlinked_registry_dir_resolves_to_one_audit_log`.
|
||||
|
||||
## 코드 및 추가 탐침 근거
|
||||
|
||||
F1: `.agents/skills/lib_py/delegation/logroot.py`가 표준 라이브러리만으로 경로 규칙을 정의한다. `control_plane.py`, lib.sh의 경로 helper, status.sh가 이 모듈을 사용한다. 패키지 초기화의 전이 import도 `mqtt_common.py:33` 및 `publisher.py:17`의 선택적 paho import로 통과한다. 실제 paho 미설치 `/usr/bin/python3` + `AGENT_PYTHON_BIN=/usr/bin/python3`에서 lib.sh의 `mam_control_plane_json`을 실행했다. status.sh의 두 `get_job_status` 정의를 그대로 추출해 동일 인터프리터에서 audit-only completed 레코드를 읽었으며 둘 다 `('probejob', 'completed')`를 반환했다. 경로 조회가 MQTT 접속을 필요로 하지 않고, 실제 client 생성은 paho가 없으면 명시적으로 실패한다.
|
||||
|
||||
F2: wrapper의 `cmd_logs`는 `--registry-dir`가 명시된 경우에만 배열에 넣어 registry.py로 넘긴다. 기본 실행은 registry.py의 MAM_JOBS_DIR → WORKSPACE_ROOT → cwd 규칙을 사용한다. 비어 있는 배열은 `${reg[@]+"${reg[@]}"}` 형태로 전개되므로 Bash 3.2 + set -u에서도 동작한다. 기본 인수 및 명시적 registry 인수 두 경우 모두 실제 테스트로 통과했다.
|
||||
|
||||
R1: `job_subscriber.py:128`에서 감사 상태를 읽은 뒤 레지스트리를 재조회하고 `:132`에서 dispatch_token/attempt_id/transition_seq를 비교한다. 첫 관찰과 다르면 그 라운드에서는 채택하지 않는다. 따라서 정상 엔진이 토큰을 교체하고 seq를 증가시키는 재디스패치가 감사 read 전후에 끼어들어도 이전 토큰에 다음 시도의 성공을 붙이지 않는다. 추가 재조회 이후에 새로운 전이가 일어나더라도 이미 읽어 둔 감사 상태를 새 상태로 바꾸지는 않는다. 기존 레지스트리 terminal 경로는 추가 read 없이 처리한다. 감사 상태를 채택할 후보에만 파일 read 최대 1회가 추가되며 새 내부 polling loop, sleep, 장시간 lock은 없다. 성능 판단은 이 코드 경로에 근거하며 대규모 잡 부하 벤치마크를 수행했다는 뜻은 아니다.
|
||||
|
||||
R2: `logroot.py:51`이 registry의 realpath에서 sibling을 구한다. submit의 `pwd -P`, control_plane의 realpath와 일치한다. `control_plane.py:82`의 비교도 로그 경로 양쪽을 정규화한다. 추가 탐침에서 `/tmp`/`/private/tmp`, `/var`/`/private/var` 부모 별칭 및 lexical 별칭을 사용하는 기존 row에서도 파생 로그 키를 제외하는 동작이 일치했다. 명시적 DELEGATE_JOB_LOGS_DIR 우선권은 유지된다. 일반 기본 경로는 같은 물리적 위치이며 Mac에서 문자열의 `/private` 접두사가 정규화되는 것은 의도된 변화이다.
|
||||
|
||||
기존 import 호환성: 실제 `/usr/bin/python3`에서 `mqtt_common.sibling_logs_dir`, `get_logs_dir`, `default_registry_dir`가 logroot의 해당 함수와 동일 객체임을 확인했다. venv Python에서 `lib_py.loop.eager.eager_load()` 실행 후 `lib_py.delegation.logroot`가 검증 트리 경로로 sys.modules에 적재됐음을 확인했다. 명시적 eager 목록에 logroot를 중복 추가하지 않아도 mqtt_common의 최상위 import를 통해 시작 시 적재된다.
|
||||
|
||||
이전 R1/R2 임시 탐침 파일은 더 이상 존재하지 않아, 이전 리포트에 기록한 동일한 전이 순서 및 symlink submit/logs 절차를 새 임시 스크립트로 재구성했다. R1은 실제 engine 전이를 사용하고 audit read 경계에만 순서를 삽입했으며, R2는 실제 wrapper의 dry-run/logs를 호출했다.
|
||||
|
||||
## 실행 결과와 한계
|
||||
|
||||
- 현재 head 선택 테스트: **83 passed in 181.30s**, exit 0.
|
||||
- 모듈 전체: `test_issue57_logs_follow_registry.py`, `test_issue15_registry_isolation.py`, `test_issue12_registry_secrets.py`, `test_issue21_worktree_prereqs.py`, `test_issue21_acceptance.py`.
|
||||
- 개별 테스트: tier1의 `test_b9_logs_dir_follows_cwd_changes`; issue18의 `test_subscriber_records_seq_gaps_and_recovers_terminal_from_state`, `test_subscriber_state_renews_liveness_only_when_it_advances`, `test_remote_runner_ignores_a_resent_dispatch_of_a_finished_attempt`, `test_remote_runner_never_rolls_a_newer_attempt_back`.
|
||||
- 변경 Python 파일 전체에 실제 Python 3.9.6의 AST parse 적용, 변경 shell/wrapper 각각 `/bin/bash -n` 검사 통과. `git diff --check base HEAD` 통과.
|
||||
- 테스트마다 브리프의 지정 환경변수를 `env -u`로 제거했고, 추가로 에이전트 관련 환경과 HOME/XDG 위치를 격리했다. 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp 사용. create/resume은 mock fixture로 검증했다.
|
||||
- 실제 herdr 서버/monitor/에이전트를 시작하지 않았다. 실제 제어 평면에는 지시된 job lifecycle 이벤트와 본 리포트만 기록했다. creator 작업 트리에서 실행하지 않았다.
|
||||
- 전체 스위트는 이 리뷰에서 다시 실행하지 않았다. 오케스트레이터의 별도 전체 스위트 결과를 이 리뷰 결과로 포함하지 않았다. 선택 테스트에서 #55 가드 오탐 및 teardown 오류는 없었다.
|
||||
- 최종 검증 트리 HEAD/tree는 브리프와 일치하고 git status는 clean이다.
|
||||
|
||||
검증 산출물 디렉터리: `/tmp/review-2e06828a-66fn_mpg/`.
|
||||
|
||||
- `before-F1.log`: 3 failed, 각각 paho import 실패.
|
||||
- `before-F2.log`: 1 failed, 잘못된 cwd 로그 루트.
|
||||
- `before-R1.log`: 1 failed, B 완료를 A에 귀속.
|
||||
- `before-R2.log`: 1 failed, alias와 actual sibling 불일치.
|
||||
- `head.log`: 83 passed.
|
||||
- `system-probe.py`, `system-probe.log`: 실제 시스템 Python/no-paho 및 import/eager/구문 검증.
|
||||
- `repro-r1-r2.py`, `repro-r1-r2.log`: 이전 두 재현 절차 및 Mac 별칭 검증.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,66 @@
|
||||
# PR #37 독립 리뷰
|
||||
|
||||
- Job: `2e264c0c`; reviewer: `planner-reviewer-codex-01`
|
||||
- Loop: `loop-20260924T080014Z-2b3df0`
|
||||
- Base: `1debc7d92444385b9200e821efa0ff0f6d82023e`
|
||||
- Head: `59be1c89209a232059aec0c5aeecc2329929cb49`
|
||||
- Tree: `a009c9062d9818f343ac57e6c72cda932887fab7`
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260924T080014Z-2b3df0/1`
|
||||
|
||||
파일 전달의 정상 경로와 기존 계약은 검증됐지만, Python 러너의 파일 쓰기 실패 정리 누락과 새 테스트의 macOS 실패가 남아 있다. 아래 두 항목을 수정한 뒤 재검토가 필요하다. 계획된 4.7.3 버전 변경과 별개의 지적이다.
|
||||
|
||||
## R1 — P2: Python 러너의 임시 파일 쓰기 실패가 정리 범위 밖에 있음
|
||||
|
||||
근거: `.agents/skills/lib_py/loop/runner.py:472`–`:477`, `:490`–`:502`.
|
||||
|
||||
`NamedTemporaryFile(delete=False)` 생성과 `fh.write()` 및 context manager 종료 시 flush/close가 `try/except/finally`보다 먼저 실행된다. 따라서 쓰기 또는 flush가 실패하면 `OSError`가 `delegate_job()` 밖으로 전파되고, 뒤의 `os.unlink(prompt_file)`에 도달하지 않는다. 특히 경로 변수도 쓰기 다음에 지정되어 있어 단순히 try 위치만 바꾸는 것으로는 충분하지 않다. 새 S9는 셸 러너의 부분 파일 정리만 검사하므로 이 Python 경로를 검출하지 못한다.
|
||||
|
||||
재현 절차:
|
||||
|
||||
1. 독립 Python subprocess에서 `WORKSPACE_ROOT`, `HOME`, 로그 경로를 새 `/tmp` 샌드박스로 설정하고, 검증 트리의 `LoopRunner(['--creator', 'c', '--task', 't'])`를 생성한다.
|
||||
2. 그 subprocess에서만 `signal.signal(signal.SIGXFSZ, signal.SIG_IGN)`과 `resource.setrlimit(resource.RLIMIT_FSIZE, (1024, 1024))`를 적용한다.
|
||||
3. `runner.delegate_job('c', 'claude', 'Worker', 'x' * 20000)`를 호출하고 예외와 `.mam/prompt-*`를 확인한다. 실제 wrapper/에이전트가 실행되기 전에 파일 쓰기에서 실패한다.
|
||||
|
||||
기대: 원인을 기록하는 정상적인 제출 실패 반환과 임시 파일 정리.
|
||||
|
||||
실제: `[Errno 27] File too large`가 처리되지 않은 `OSError`로 전파됐고, `.mam/prompt-loop-…md`가 **1,024바이트, 권한 0600**으로 남았다. 실제 `LoopRunner.delegate_job()`으로 재현했으며, 오류를 흉내 내는 subprocess mock은 사용하지 않았다. 생성/쓰기 실패를 처리해야 한다는 이번 브리프의 요구를 충족하지 못한다.
|
||||
|
||||
수정 방향: 임시 파일 경로를 미리 초기화하고, 디렉터리 생성·파일 생성·쓰기·close·wrapper 실행을 정리 가능한 하나의 try/finally 범위에 둔다. 생성된 파일의 이름은 `write()` 전에 저장한다. 파일 준비 실패도 원인이 드러나는 실패 제출 결과로 돌려주고, 첫 시도와 busy 재시도에서 같은 규칙을 적용한다. 메시지는 wrapper 실행 실패와 파일 준비 실패를 구분하는 것이 정확하다.
|
||||
|
||||
대안 검증: 검증 트리에서 추출한 동일 `_run_cmd`를 임시 harness에서만 변경해, 위 작업들을 기존 예외/정리 범위 안으로 옮기고 경로를 쓰기 전에 저장했다. 같은 실제 파일 크기 제한에서 원본은 예외와 1,024바이트 파일을 남겼지만, 후보는 **rc 126 실패 결과와 빈 leftovers 목록**을 반환했다. 제품 코드는 수정하지 않았다. 이 상황을 Python 러너의 회귀 테스트에도 추가해야 한다.
|
||||
|
||||
## R2 — P2: 새 S9가 macOS에서 플랫폼별 오류 문구 때문에 실패함
|
||||
|
||||
근거: `tests/test_issue36_prompt_transport.py:602`.
|
||||
|
||||
재현: 브리프의 인터프리터와 격리 옵션으로 `tests/test_issue36_prompt_transport.py::test_s9_shell_loop_removes_a_partial_prompt_file_and_says_why`를 실행한다.
|
||||
|
||||
기대: 실제 `RLIMIT_FSIZE` 쓰기 실패, 비정상 종료, 부분 파일 제거, 프로젝트가 출력하는 원인 메시지를 검증하며 지원 플랫폼에서 통과해야 한다.
|
||||
|
||||
실제: 전체 선택 실행과 S9 단독 재실행 모두 `assert "File too large" in result.stderr`에서 실패했다. 이 macOS 실행 환경의 bash stderr에는 그 문구가 없었다. 프로젝트 자체의 `delegate_job_safe: cannot write the prompt file ...` 메시지는 출력됐다.
|
||||
|
||||
수정 방향 및 검증: 플랫폼별 bash/OS 오류 문자열을 필수 계약으로 삼지 말고, 비정상 반환 코드·부분 파일 없음·프로젝트의 고유 오류 메시지를 유지해서 검사한다. 임시 namespace에서 해당 영어 문자열 assertion 한 줄만 제외한 동일 S9를 새 샌드박스에 실행했으며, 나머지 **실제 쓰기 실패와 정리/진단 assertion은 모두 통과**했다. 테스트 전체를 skip하거나 정리 assertion을 제거할 필요는 없다.
|
||||
|
||||
## 나머지 검토 결과
|
||||
|
||||
- 전달 경로: `--prompt` 잔여 호출자를 직접 검색했다. 옛 셸 러너의 11개 내부 호출은 shell 함수 `delegate_job_safe`로 들어가며, 이 함수가 exec 전에 파일로 변환한다. Python 러너, lib.sh의 Python/legacy 제출 도우미, delegation CLI, wrapper의 registry register/update 경로는 파일 전달을 사용한다. 명시적 `--prompt` CLI 호환 경로는 남아 있다.
|
||||
- 경로/크기: 실제 이 호스트의 `SC_ARG_MAX`보다 큰 **1,114,112바이트** 프롬프트를 argv로 전달하면 `OSError(errno=7)`가 발생했다. 같은 내용은 상대경로의 공백 포함 파일명, 다른 `--workdir`, 공백 포함 registry 경로 조합으로 실제 wrapper/registry를 거쳐 원문 그대로 등록됐다. Linux 단일 인자 131,072바이트 상한을 macOS에서 재현했다고 주장하는 것이 아니다.
|
||||
- 파일 읽기 시점: wrapper가 registry를 동기 실행하고 registry가 그 호출 중 파일 내용을 읽으므로, 호출자 쪽 정상 반환 후 임시 파일 삭제와 충돌하지 않는다. 정상 완료/exec OSError의 정리 테스트는 통과했다. R1은 그보다 앞선 쓰기 실패에 한정된다.
|
||||
- 실패 처리: exec 실패와 busy 재시도의 rc 126 처리, 재심/중재/순응 미등록 시 비정상 종료 테스트가 통과했다. 별도 실제 루프 subprocess에서 fake agent가 순응 잡을 등록한 뒤 `error` 상태로 종료하도록 했을 때도 rc 1로 끝났고 다음 리뷰 라운드/성공 승격으로 진행하지 않았다. 구현·일반 수정 잡의 실패를 중단하는 기존 정책과 일관된 강화다. 다만 이때도 “Failed to register Creator compliance job”이라고 출력하는 문구는 등록 실패와 실행 실패를 구분하지 못한다(비차단 진단 개선 사항).
|
||||
- diff 제한: 수집기의 바이트/줄 제한값은 바뀌지 않았고 증거 절단 표시도 유지된다. L15는 잘린 증거를 받는 diff-only 원격 리뷰어를 거부하고 로컬 리뷰는 허용하는 기존 규칙을 검증했다.
|
||||
- create 온보딩: S7 계열과 새 S8이 통과했다. brief는 job registry 아래 절대경로로 만들어지고, 주입문은 과제 본문 대신 그 경로와 기존 dispatch-token/started 명령을 포함한다. 임시 제출 파일과 에이전트가 이후 읽을 영속 brief는 구분된다.
|
||||
- 하위 호환: registry CLI 계약 스냅샷, shim help 캡처, loop CLI, worktree 문맥, Issue #14 acknowledgement 선택 테스트가 통과했다. `--prompt`는 계속 사용할 수 있다. 대표 역검증으로 샌드박스의 wrapper만 base 버전으로 교체하면 새 S1은 `unknown option: --prompt-file`로 실패했다. PR이 언급한 13건 전체의 과거 버전 실패 수를 재측정한 것은 아니다.
|
||||
- 문서: delegate-job skill은 파일 옵션과 Linux argv 제한을 설명하고, loop skill은 위임 실패의 비정상 종료 및 pipefail 사용을 설명한다. 검증 트리에 `.claude/skills` 배포 사본은 없었다. 4.7.3 락스텝 bump는 브리프대로 오케스트레이터 후속 작업이다.
|
||||
|
||||
## 실행 증거
|
||||
|
||||
모든 pytest 실행은 검증 경로에서 브리프의 절대 Python 경로를 `AGENT_PYTHON_BIN`과 실행 명령에 함께 지정하고, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 경로 밖의 고유 `--basetemp`를 사용했다.
|
||||
|
||||
1. `test_issue36_prompt_transport.py`, `test_issue5_phase1_delegation.py`, `test_loop_cli.py`: **95 passed, 1 failed, 1 skipped — 91.15초**. 실패는 R2의 S9이며, skip은 Linux 전용 MAX_ARG_STRLEN 음성 대조군이다.
|
||||
2. `test_issue23_24_startup_path.py`, `test_issue21_loop_b.py`, `test_issue21_worktree_prereqs.py`, `test_issue14_delivery_ack.py`에서 `s7 or l15 or t2_5 or wrapper_honours_registry_ack` 선택: **19 passed, 132 deselected — 63.39초**.
|
||||
3. S9 단독 확인: 동일 문구 assertion에서 실패. 위 결과에 중복 합산하지 않았다.
|
||||
4. `git diff --check`, 변경 셸 파일 5개의 개별 `bash -n`, 변경 Python 파일 4개의 AST 구문 검사 통과.
|
||||
|
||||
HEAD/tree가 브리프와 일치했고 검증 후 git 작업 트리도 깨끗했다. 제품 코드, creator 워크트리, 실제 herdr 서버/에이전트 세션을 수정하지 않았다. 제안 검증과 역검증은 임시 사본/독립 subprocess에 한정했다. 두 지적은 국소 수정으로 해결 가능하므로 재설계 에스컬레이션은 필요하지 않다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,112 @@
|
||||
# PR #43 review — job 3001f1d3
|
||||
|
||||
판정: **NOT PASS**. 로컬 MQTT 전환의 검증은 강화됐지만, 설정 파일 읽기 실패 처리와 두 정수 정규화 구현의 동작에 재현 가능한 차이가 남아 있다. 아래 수정은 현재 설계 안에서 가능하므로 재계획 에스컬레이션은 요청하지 않는다.
|
||||
|
||||
## 검증 identity와 범위
|
||||
|
||||
- loop: `loop-20261001T131705Z-a60092`
|
||||
- base: `e1eb22bd59ce825e3a22e86433c30f7ffd833810`
|
||||
- head: `b58edace115205500c1197d9ce59cb5c6d724893`
|
||||
- tree: `14b670fbfc82dc9179cab43cf53eef920a3d6ffa`
|
||||
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261001T131705Z-a60092/1`
|
||||
- 인터프리터: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python`
|
||||
|
||||
검증 트리의 HEAD/tree와 clean 상태를 확인했다. 프로젝트 코드, 브랜치, 실제 세션은 변경하지 않았다. 실제 `.mam.env`를 읽거나 export하지 않았다. 테스트 및 탐침의 설정·레지스트리·HOME·산출물은 임시 디렉터리를 사용했다. 기준 비교는 `git archive <base>`로 만든 `/tmp/3001f1d3-base` 사본의 purge 테스트만 같은 인터프리터와 검증 cwd에서 실행했다.
|
||||
|
||||
## 수정 필요 사항
|
||||
|
||||
### R1 — [P2] 설정 파일을 읽을 수 없는데 래퍼가 성공으로 계속 진행한다
|
||||
|
||||
**근거:** `.agents/skills/lib_py/delegation/envfile.py:52`는 `OSError`와 `UnicodeDecodeError`를 빈 설정과 경고로 바꾸고, `:94`에서 이를 받은 CLI는 `:99`에서 항상 0을 반환한다. 래퍼 `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:42`의 실패 검사는 따라서 이 오류를 잡지 못한다.
|
||||
|
||||
**재현:** 임시 파일에 `PROBE_A=one`을 쓰고 `chmod 000`한다. 해당 파일을 `MAM_ENV_FILE`로 지정하고 실제 래퍼의 `--help`를 실행한다. HOME/WORKSPACE_ROOT와 로그·레지스트리도 임시 경로로 지정한다. 확인 후 파일 권한을 복원한다.
|
||||
|
||||
**기대:** 파일 전체 읽기 실패는 명시적 오류와 nonzero 종료. 잘못된 개별 줄을 경고 후 건너뛰는 규칙과 구분해야 한다.
|
||||
|
||||
**실제:** head는 `cannot read (PermissionError)` 경고와 함께 **rc 0**, usage 출력을 반환했다. 같은 파일과 환경의 base 래퍼는 **rc 1 / Permission denied**였다. 잘못된 UTF-8 파일도 head에서 경고와 rc 0이었다. 실제 submit에서는 필요한 설정 없이 기본 설정으로 진행할 수 있다. 기존 `test_a1_an_unreadable_parser_fails_loudly`는 이름과 달리 파일 읽기 실패가 아니라 Python 실행파일 부재만 시험한다(`tests/test_issue26_envfile.py:138`).
|
||||
|
||||
**수정 방향:** 공통 파서에서 전체 읽기/디코딩 실패를 개별 줄 경고와 별도로 표현하고 CLI 및 Python 진입점으로 실패를 전달한다. 기존의 선택적 파일 부재 처리와 개별 잘못된 줄 건너뛰기는 유지한다. 실제 권한 오류 및 invalid UTF-8에 대한 wrapper rc 회귀 검증을 추가한다.
|
||||
|
||||
### R2 — [P2] 로케일에 따라 Bash와 Python이 같은 정수 입력을 다르게 해석한다
|
||||
|
||||
**근거:** `.agents/skills/lib.sh:2346`은 로케일 의존 `[[:space:]]`를 사용한다. `.agents/skills/lib_py/loop/runner.py:74`는 ASCII 공백 6개만 제거하고 `:75`에서 ASCII 숫자만 허용한다. `runner.py:483`은 이 결과를 명시적인 `--timeout`으로 전달한다.
|
||||
|
||||
**재현:** `LC_ALL=en_US.UTF-8`, `MAM_JOB_TIMEOUT_S='\u200330\u2003'`를 실제 U+2003 문자로 설정한다. 임시 HOME/WORKSPACE_ROOT, 없는 MAM_ENV_FILE로 (a) 실제 wrapper `submit --agent human --dry-run`의 registry `timeout_sec`, (b) 실제 `LoopRunner(...).parse_args()` 후 `job_budget`/`contract_args()`를 비교한다. 재현 코드: `/tmp/3001f1d3-probe2.py`.
|
||||
|
||||
**기대:** 동일 입력은 두 경로에서 같은 예산 또는 같은 기본값으로 정규화된다.
|
||||
|
||||
**실제:** 직접 wrapper는 **30초**로 등록했다. runner는 invalid 경고 후 **3600초**, `['--timeout', '3600']`을 선택했다. U+00A0 및 U+3000도 helper 비교에서 같은 불일치를 보였다. `LC_ALL=C`에서는 Bash도 거부했다. 정상 ASCII 공백과 `0700`은 C/UTF-8 모두 정상이다. 현재 표 기반 테스트는 유니코드 공백과 로케일을 포함하지 않는다.
|
||||
|
||||
**수정 방향:** 문자 집합을 명시하고 로케일에 독립적인 한 규칙으로 통일한다. 브리프의 Python 판정 원칙을 따르면 공통 Python 정규화기를 Bash가 호출하는 방식이 적합하다. Bash 구현을 유지해야 한다면 최소한 함수 내부 C 로케일/명시적 ASCII 공백 등으로 범위를 고정하고 두 로케일에서 실제 wrapper와 runner를 비교한다. C 로케일에서 위 불일치가 없어지는 것은 별도 탐침으로 확인했다.
|
||||
|
||||
### R3 — [P2] 명시적으로 전달한 빈 정수 플래그가 검증을 우회한다
|
||||
|
||||
**근거:** `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:197`의 `[ -n "${!_var}" ] || continue`는 미지정 optional 값과 사용자가 전달한 빈 값을 구분하지 않는다. `:201`은 빈 idle timeout을 기본값으로 다시 만든다.
|
||||
|
||||
**재현:** 격리된 임시 환경에서 실제 래퍼를 `submit --agent human --dry-run --prompt probe --registry-dir <tmp> --idle-timeout ''`로 실행한다. 다른 네 플래그도 `MAM_DELEGATE_JOB_NO_MAIN=1`로 실제 래퍼를 source한 뒤 `parse_opts <flag> ''`로 확인했다.
|
||||
|
||||
**기대:** 명시적 빈 문자열은 양의 10진수 플래그가 아니므로 등록 전에 rc 1과 해당 플래그 오류 메시지.
|
||||
|
||||
**실제:** `--idle-timeout ''`은 **rc 0**으로 잡을 등록하고 `idle_timeout_sec=120`을 기록했다. 다섯 플래그 모두 `parse_opts`에서 수락된다. `--heartbeat ''` 또는 `--heartbeat-misses ''`는 그 단계에서 파생 idle timeout을 0으로 만든다. 후속 argparse가 일부 빈 값을 거부하더라도 새 검증의 일관된 rc 1 계약을 충족하지 못한다.
|
||||
|
||||
**수정 방향:** 명시적 플래그 값은 소비 시 검증하거나 presence를 기록하여 빈 값도 검증한다. 미지정 `--idle-timeout`에만 파생 기본값을 적용한다. 다섯 플래그의 빈 문자열을 현재 invalid-value 표에 추가하고 잡 미등록까지 검증한다.
|
||||
|
||||
### R4 — [P2] NUL이 들어간 값은 Python 로더를 종료시키고 Bash에서는 다른 값이 된다
|
||||
|
||||
**근거:** `.agents/skills/lib_py/delegation/envfile.py:69`–`:72`는 NUL을 값으로 수락한다. `:82`의 실제 `os.environ` 대입은 `ValueError`를 발생시키지만 `:94`의 일반 dict는 수락하며 `:98`에서 그대로 출력한다. `mqtt_common.py:100`에는 이 예외 처리가 없다.
|
||||
|
||||
**재현:** 임시 파일을 정확히 `b'GOOD=ok\nVALUE=left\x00right\nAFTER=yes\n'` 바이트로 쓰고 같은 파일을 두 진입점에 지정한다. 실제 wrapper를 source한 후 세 환경값을 출력하고, 별도 Python 프로세스에서 `mqtt_common.ensure_env_loaded()`를 호출한다. 재현 코드: `/tmp/3001f1d3-probe.py`.
|
||||
|
||||
**기대:** OS 환경변수로 표현할 수 없는 값은 공통 파서에서 값 없는 줄 번호 경고와 함께 거부되거나, 두 진입점 모두 같은 명시적 실패를 반환한다.
|
||||
|
||||
**실제:** wrapper는 **rc 0**, `VALUE=leftright`, `AFTER=yes`. Python은 **rc 1**, `ValueError: embedded null byte`로 종료하여 뒤의 설정을 적용하지 못한다. 현재 “하나의 입력 → 같은 값/경고” 계약 밖의 차이다.
|
||||
|
||||
**수정 방향:** 공통 파서에서 NUL을 거부한다. 값이나 전체 원문을 경고에 포함하지 않는다. 정상 앞뒤 줄 처리와 두 실제 진입점의 동일 동작을 회귀 검증한다.
|
||||
|
||||
## 브리프의 7개 검토 항목
|
||||
|
||||
1. **파서 규칙:** `envfile.py:50`–`:83`이 양쪽의 공통 구현이다. 키/값 바깥 공백 제거, 한 쌍의 따옴표 제거, 값 내부 `=`, 빈 값, `export KEY=` 거부, 잘못된 키/줄 경고, 중복 키의 첫 값 우선은 코드와 기존 테스트에서 일치한다. CRLF는 두 줄을 정상 파싱했고 BOM은 첫 키를 invalid key로 건너뛰었다(탐침 확인). BOM 지원을 약속한 문서는 없으므로 이것 자체를 결함으로 판정하지 않았다. R4의 표현 불가능한 값은 불일치한다. `.mam.env.example:32`, `:36`, `:49`, `:110`의 경로 예시는 확장 없는 절대경로로 바뀌어 파서와 맞는다. 다만 템플릿 헤더 `:10`에는 env 우선, shell expansion 없음, `export`/inline comment/중복 키/BOM 규칙 설명이 없다. 사용자 문서에 문법과 이전 source 방식에서의 이행 예를 추가하는 것을 권고한다.
|
||||
|
||||
2. **eval 경로:** `envfile.py:21`, `:63`, `:66`, `:98`의 키 fullmatch/예약 변수 차단/shlex.quote가 일반 문자열의 명령 치환과 따옴표 탈출을 막는다. wrapper `:42`는 자신의 세 경로 변수를 parser env에 넣어 파일로 교체되지 않게 한다. 일반 값과 예약 변수 공격 테스트에 우회는 관찰하지 않았다. `-I`는 stdlib-only 파일을 절대 경로로 실행하므로 이 파서에는 PYTHONPATH 불필요하며 격리가 적절하다. Python 실행 불가 시 wrapper rc 1+ERROR는 테스트로 검증된다. 파일 읽기 실패는 R1, NUL은 R4와 같이 별도 수정이 필요하다.
|
||||
|
||||
3. **우선순위 변경:** `envfile.py:81`의 key 존재 검사로 기존 env가 빈 문자열이어도 파일을 이긴다. 이전 wrapper source는 파일이 덮어썼으므로 의도적인 호환성 변경이다. runner의 env 계약(`runner.py:477`) 및 creator에 전달되는 control-plane 네 env(`lib_py/control_plane.py:24`, `:70`; create script `:230`, `:242`)를 작업 트리 파일이 덮어쓰지 않게 된다는 이점이 있다. 반대로 기존 pane의 broker/budget env가 오래됐으면 파일 수정만으로 바뀌지 않으므로 unset 또는 새 환경이 필요하다. Python remote 진입점(`remote.py:463`)은 이전에도 env 우선이었다. 테스트 env/MAM_ENV_FILE 격리와도 맞는다. 호환성 변경은 코드 주석과 테스트(`test_issue26_envfile.py:184`)에는 있지만 사용자용 템플릿/README에는 설명이 추가되지 않았다.
|
||||
|
||||
4. **정수 노브:** `tests/test_issue26_ints.py:35`의 표에 해당하는 shell arithmetic 입력은 공통 Bash helper를 통과한다. 예산/heartbeat/misses/ack 및 명시적 timeout 플래그의 min=1, inject/onboard/SKS 대기의 min=0은 기존 의미와 맞는다. 유효 9자리 경계와 선행 0 제거는 큰 입력을 int/arithmetic 전에 제한한다. 다만 R2/R3 때문에 “같은 규칙”은 아직 성립하지 않는다. PR 범위 밖의 기존 Python 노브(`runner.py:580` retry, `:628` wait grace, `:749` child wait, `:752` tree max)와 loop-count CLI(`:208`, `:223`, `:235`)는 이 helper를 사용하지 않는다. 따라서 모든 정수 설정이 통일됐다고 문서화하면 안 된다. 이들은 기존 코드이며 이번 변경의 신규 회귀로 별도 반려하지 않는다.
|
||||
|
||||
5. **로컬 MQTT:** `tests/_local_mqtt.py:90`의 localhost port 0 bind가 포트 배정을 OS에 맡긴다. `:180` 이후 실제 CONNECT/CONNACK, QoS 0/1, `+`/`#`, SUBACK, retained 저장/재전달/빈 payload 삭제, UNSUBACK, PING을 구현한다. PUBACK 전에 route하는 순서와 실제 paho 응답 대기 테스트(`test_local_mqtt.py:100`, `:140`, `:173`)가 wire 동작을 검증한다. 변경된 기존 테스트에서 의미 있는 단언 삭제는 찾지 못했다. 외부 브로커 실패 skip을 실패로 바꾸고 remote/heartbeat 수신 감사 로그 단언을 추가했다(`test_issue18_liveness_contract.py:1098`, `:1228`; `test_issue19_subscriber_daemon_lifecycle.py:422`). static scan은 `tests/*.py` 범위의 재유입 방지이지 모든 가능한 외부 네트워크를 차단하는 sandbox는 아니다. session fixture는 env를 복원하고 broker를 stop한다(`conftest.py:1569`); 이 호스트의 별도 start/stop 탐침에서 0.4초 후 broker thread 잔류는 없었다. 구현은 daemon thread를 join하지 않으므로 모든 플랫폼/종료 경합에 대한 누수 부재까지 증명한 것은 아니다. TLS/auth/persistent session/QoS2 등은 명시적 비지원 범위다.
|
||||
|
||||
6. **기준 실패 주장:** 이 호스트의 head와 base에서 `tests/test_issue10_13_purge.py`는 각각 **23 passed**, 실패 0건이었다. base 파일과 테스트 fixture를 archive 사본에서 실행했다. UUID4 및 임시 HOME/registry 격리를 먼저 확인했다(`test_issue10_13_purge.py:51`, `:138`; `conftest.py:193`). 따라서 “13개 실패 중 12개가 이 호스트의 기준 purge 실패”는 이번 검증으로 뒷받침되지 않는다. 과거 실행의 환경 차이/로그 없이는 그 당시 결과를 부정하거나 나머지 1건 원인을 단정할 수도 없다. PR 본문에 현재 재현 결과 또는 당시 명령·환경 차이를 명시해야 한다.
|
||||
|
||||
7. **설계 경계 #17/#42:** 파일 문법 판정의 Python 단일화는 목표와 맞는다. 정수 부분은 `lib.sh:2345`/`:2359`에 새로운 수락/거부/기본값/경고 판정을 두고 `runner.py:71`에 별도 구현을 복제했다. 따라서 브리프의 “판정은 Python” 경계에 완전히 부합하지 않으며 R2가 실제 drift 증거다. 공통 Python 규칙으로 정리하거나 제한적 Bash 예외를 명시적으로 합의해야 한다. 이 평가는 브리프가 제시한 합의와 저장소 설계 문서(`docs/design/worktree-creator-model.md:116`)를 기준으로 하며, 원격 이슈 댓글 원문까지 독립 확인했다고 주장하지 않는다.
|
||||
|
||||
## 검증 결과와 재현 산출물
|
||||
|
||||
- 브리프 지정 7개 모듈: **228 passed, 1 skipped in 527.11s**, rc 0. 유일한 skip은 macOS에서 `/proc` 기반 process-tree 테스트를 제외하는 `tests/test_issue30_stall_watch.py:40` 조건이다.
|
||||
- 추가 변경 모듈(`test_issue19_subscriber_daemon_lifecycle.py`, `test_issue6_fail_closed.py`, `test_b19_headless_reconcile_fixes.py`): **61 passed in 173.09s**, rc 0.
|
||||
- purge 비교: head **23 passed in 11.24s**, base **23 passed in 9.96s**, 모두 rc 0.
|
||||
- `git diff --check <base> HEAD`, 두 변경 shell 파일의 `bash -n`, 변경 Python 파일의 AST syntax 검사: 모두 통과.
|
||||
- 테스트 종료 후 검증 트리 `git status --short`와 `git diff --quiet`: clean.
|
||||
|
||||
지정 테스트 명령(위 검증 cwd):
|
||||
|
||||
```bash
|
||||
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-43/.venv/bin/python \
|
||||
-m pytest -p no:cacheprovider -q --basetemp=/tmp/3001f1d3-selected \
|
||||
tests/test_issue26_envfile.py tests/test_issue26_ints.py tests/test_local_mqtt.py \
|
||||
tests/test_issue18_liveness_contract.py tests/test_issue33_budget.py \
|
||||
tests/test_issue30_stall_watch.py tests/test_tier1_unit.py
|
||||
```
|
||||
|
||||
로그: `/tmp/3001f1d3-selected.log`, `/tmp/3001f1d3-migrated.log`. 추가 모듈도 같은 환경/옵션에 별도 `--basetemp=/tmp/3001f1d3-migrated`를 사용했다. 실제 `.mam.env`는 export하지 않았다.
|
||||
|
||||
추가 탐침 로그:
|
||||
- `/tmp/3001f1d3-probe.log`: 빈 플래그, NUL, CRLF/BOM, invalid UTF-8, Unicode whitespace, broker stop.
|
||||
- `/tmp/3001f1d3-probe2.log`: 실제 dry-run wrapper와 runner의 예산 차이, 실제 빈 idle-timeout 등록, PermissionError.
|
||||
- `/tmp/3001f1d3-purge-head.log`: 23 passed in 11.24s.
|
||||
- `/tmp/3001f1d3-purge-base.log`: 23 passed in 9.96s.
|
||||
|
||||
탐침 재실행은 위 검증 cwd에서 `PYTHONDONTWRITEBYTECODE=1 <위 인터프리터> /tmp/3001f1d3-probe.py "$PWD"` 및 `probe2.py`로 가능하다. 모두 자체 임시 디렉터리와 합성 설정만 사용한다. 전체 약 75분 스위트는 지시대로 실행하지 않았다.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,19 @@
|
||||
# PR #62 / Issue #60 Linux follow-up review
|
||||
|
||||
**Scope.** Reviewed base `1d7b7dab58a10fe86fd747b7290d47674ca93230` through head `6a4e385a78c8a561d5cf3644336de6a352ea9271` in the assigned review-only worktree. HEAD and tree matched the brief; the worktree was clean. No source files, branches, services, or user data were changed.
|
||||
|
||||
**NOT PASS findings**
|
||||
|
||||
1. **`auth_ok()` accepts a non-executable home install.** At `.agents/skills/lib_py/agents/adapters/opencode.py:317-321`, the `PATH` candidate is checked by `shutil.which`, but the home candidate is accepted by `os.path.exists` alone. Reproduction: with a temporary HOME containing `.opencode/bin/opencode` mode `0644`, an empty PATH, and no XDG variables, `adapter.auth_ok()` returned `True`. `create_session.sh` now relies on this result as its preflight, so it can report the installation as functional and proceed although the CLI cannot be executed. Check that the home candidate is a regular executable file (e.g. `os.path.isfile` and `os.access(..., os.X_OK)`), and cover a non-executable file and directory in the auth tests.
|
||||
|
||||
2. **The placeholder regex can discard a real draft.** `.agents/skills/lib_py/agents/adapters/opencode.py:76-79` makes the example suffix optional, so the pattern matches the exact user text `Ask anything…`. Replaced the captured prompt line in the 2.x home fixture with that text and `extract_input_region()` returned `''`. Thus the existing input-empty signal loses this exact draft and can treat a non-empty prompt as idle. The observed 2.x placeholder fixtures include an opening quote and example, including the truncated narrow capture; constrain the pattern to that observed shape and add the exact-short-text draft as a regression case. Other tested similar strings are retained.
|
||||
|
||||
3. **Five newly added Mac tests fail before exercising `_ro_connect`.** In `tests/test_issue60_linux_fixes.py:153-155`, the `clean` fixture closes a WAL-mode connection and assumes macOS SQLite removes both sidecars. On this Mac the files remain after close, so `make("clean")` returns `opencode.db`, `opencode.db-shm`, and `opencode.db-wal` and the assertion at `:181` fails before the query runs. This affects the five `_descendants`, `_has_session`, `discover`, `residual_artifacts`, and `verify_artifact` parameterizations. Normalize the fixture to a genuine no-WAL state after closing/checkpointing the writer (or construct the clean database in rollback-journal mode) before asserting the directory contents.
|
||||
|
||||
**Validation.** On macOS / Python 3.9.6, ran the focused suite with temporary HOME/TMPDIR, XDG variables unset, the required environment scrub, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and an external temporary basetemp:
|
||||
|
||||
`tests/test_issue60_linux_fixes.py tests/test_a4_adapter_contract.py tests/test_issue31_purge_residual.py tests/test_issue50_dispatch.py`
|
||||
|
||||
Result: **189 passed, 5 failed** (136.27s). All five failures share the clean-fixture setup issue above; the other WAL states and remaining focused tests passed. A separate no-WAL temporary DB probe exercised `verify_artifact`, `discover`, `_has_session`, `_descendants`, and `residual_artifacts`; each returned the expected result and left only `opencode.db`. The non-executable-file probe reproduced finding 1; the captured-pane probe reproduced finding 2. `git diff --check` and `bash -n` on the changed create/stop scripts passed. No actual OpenCode service or real data was accessed. Linux was not independently rerun in this turn.
|
||||
|
||||
[VERDICT: NOT PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261008T234013Z-4f5d21",
|
||||
"base_sha": "97c2946ee0e11eabcecafe6407755c33c0289218",
|
||||
"head_sha": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
|
||||
"tree_sha": "741c19ba4c17601768b06684d5843d218a72beb1",
|
||||
"review_commit": "a93c9bf29279f65ce0b9c9fb2c1aeea90a659420",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "527f1409",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-09T00:45:03Z"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# Review — 527f1409
|
||||
|
||||
## 판정
|
||||
|
||||
PASS. 누적 변경에서 차단할 결함을 찾지 못했다. O3의 두 실제 run_loop 실행이 테스트 tmp_path 아래의 marker를 사용하고, 기존 잠금 경합/생성/해제 테스트의 marker는 유지된다. 새 회귀 테스트는 수정 전의 빈 `.mam/` 생성도 탐지한다.
|
||||
|
||||
## Review identity
|
||||
|
||||
- loop: `loop-20261008T234013Z-4f5d21`
|
||||
- base: `97c2946ee0e11eabcecafe6407755c33c0289218`
|
||||
- head: `a93c9bf29279f65ce0b9c9fb2c1aeea90a659420`
|
||||
- tree: `741c19ba4c17601768b06684d5843d218a72beb1`
|
||||
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261008T234013Z-4f5d21/1`
|
||||
- Python: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/loop-marker/.venv/bin/python` — 3.9.6.
|
||||
- Bash: GNU bash 3.2.57(1)-release, arm64-apple-darwin25.
|
||||
|
||||
identity를 확인했고 git status는 깨끗하다. `git diff --check 97c2946 HEAD` 및 변경 Python 파일 3개의 AST 파싱이 통과했다. `.agents/skills`에는 변경이 없다. creator 소스 작업 트리에서 실행/수정하지 않았고 검증 트리의 코드도 수정하지 않았다.
|
||||
|
||||
## 변경 검토
|
||||
|
||||
- `tests/test_o3_scoped_guard.py:391`: reexec argv 테스트의 subprocess 환경에 tmp_path/loop-guard-active를 명시한다. 기존 환경은 dict(os.environ)으로 보존하므로 marker만 바뀐다.
|
||||
- `tests/test_o3_scoped_guard.py:470`: no-freeze 테스트에도 같은 방식으로 임시 marker를 지정한다.
|
||||
- 수정 대상이 한 모듈의 두 호출이므로 공통 fixture나 전역 autouse 없이 직접 지정한 범위가 적절하다. 다른 O2/O3 테스트가 의도적으로 만든 marker 경로는 건드리지 않는다.
|
||||
- `tests/test_loop_marker_isolation.py:34`: checkout 복사본에서 실제 관련 테스트들을 nested pytest로 실행한 뒤 `.mam` 디렉터리 자체가 없음을 검사한다. 실행 실패나 skip을 성공으로 취급하지 않는다.
|
||||
- `tests/test_issue17_workspace_root_isolation.py:71`: 이전의 하위 항목 목록 검사에 디렉터리 부재 검사를 추가한다. 잠금을 해제하면서 marker만 삭제하고 빈 `.mam/`을 남기는 경우까지 잡는다.
|
||||
|
||||
## 실제 실행 결과
|
||||
|
||||
이전 작업의 샌드박스 제한은 현재 해소되어, 이번에는 **정상 conftest와 실제 로컬 MQTT broker를 그대로 사용했다**. fixture를 제외하거나 ps를 대체하지 않았다.
|
||||
|
||||
실행 전 HERDR_*, MAM_*, MQTT_*, SKS_*, WORKSPACE_ROOT 및 상속된 control-plane 경로 환경을 제거했다. HOME은 새 임시 경로로 지정했다. `.mam.env`를 export하거나 내용을 출력하지 않았다. bytecode/cache 쓰기는 비활성화하고 basetemp/로그는 검증 트리 밖에 두었다.
|
||||
|
||||
실행 파일(모두 전체 파일):
|
||||
|
||||
- `tests/test_o2_race_free_lock.py`
|
||||
- `tests/test_o3_scoped_guard.py`
|
||||
- `tests/test_issue17_workspace_root_isolation.py`
|
||||
- `tests/test_loop_marker_isolation.py`
|
||||
|
||||
따라서 변경한 테스트 파일 전부와 브리프의 필수 동반 파일이 포함된다. W1과 L1의 nested pytest도 실제로 완료되었다.
|
||||
|
||||
| 조건 | 결과 | 외부 sentinel | 검증 checkout `.mam/` |
|
||||
|---|---|---|---|
|
||||
| WORKSPACE_ROOT unset | 57 passed, 47.85s | 0 entries | 실행 전후 없음 |
|
||||
| WORKSPACE_ROOT=기존 빈 sentinel | 57 passed, 48.29s | 0 entries | 실행 전후 없음 |
|
||||
|
||||
O2의 live-holder 거부, 경합 단일 승자, stale 회수, marker 보존, O3의 잠금 생성/해제 및 guard 동작이 통과했다. Python 3.9 및 macOS 기본 Bash 3.2에서 검증했다.
|
||||
|
||||
재현 드라이버/로그:
|
||||
|
||||
- `/tmp/527f1409-run.py`
|
||||
- `/tmp/527f1409-unset.log`
|
||||
- `/tmp/527f1409-sentinel.log`
|
||||
|
||||
드라이버는 환경을 정리한 뒤 해당 검증 cwd에서 다음 형태로 실행한다:
|
||||
|
||||
```text
|
||||
<loop-marker venv>/bin/python -m pytest -p no:cacheprovider --basetemp=<외부 임시 경로>/bt -q --tb=short <위 4개 파일>
|
||||
```
|
||||
|
||||
임시 실행 root:
|
||||
|
||||
- unset: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/527f1409-unset-77llo387`
|
||||
- sentinel: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/527f1409-sentinel-p9dxhcge`
|
||||
|
||||
각 root의 sentinel 디렉터리가 비어 있음을 실행 후 확인했다.
|
||||
|
||||
## 수정 전 실패 확인
|
||||
|
||||
`/tmp/527f1409-negative.py`에서 **동일한 새 L1 회귀 함수**를 실행하되, `_copy_checkout`가 만든 외부 임시 복사본의 O3 테스트 파일만 base `97c2946` 버전으로 교체했다. 제품 코드는 base와 head가 동일하다. 원본 검증 트리나 creator 소스는 수정하지 않았다.
|
||||
|
||||
절차:
|
||||
|
||||
1. 현재 checkout을 임시 디렉터리 아래에 복사한다.
|
||||
2. 그 복사본의 `tests/test_o3_scoped_guard.py`만 `git show 97c2946:tests/test_o3_scoped_guard.py` 내용으로 바꾼다.
|
||||
3. 새 L1 함수가 선택한 실제 테스트들을 그대로 실행한다.
|
||||
4. nested pytest의 성공/통과 수 검사 이후, 최종 `.mam` 부재 assertion에서 실패하는지 확인한다.
|
||||
|
||||
실제 결과:
|
||||
|
||||
```text
|
||||
NEGATIVE_ASSERTION a test took the checkout's loop lock (its .mam was created)
|
||||
COPY_MAM_EXISTS True
|
||||
COPY_MAM_ENTRIES []
|
||||
```
|
||||
|
||||
즉 테스트 본체가 성공해도 잠금 디렉터리가 남는 기존 결함을 정확히 재현했다. 현재 head에서는 같은 L1이 두 환경 모두 통과한다. 단순 테스트 실행 실패를 negative control 성공으로 센 것이 아니다.
|
||||
|
||||
- 로그: `/tmp/527f1409-negative.log`
|
||||
- negative root: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/527f1409-negative-x1lkngmg`
|
||||
|
||||
## 실행 지점 조사 및 제외 근거
|
||||
|
||||
직접 `rg`로 run_loop.sh, RUN_LOOP, MAM_LOOP_MARKER, MAM_REAL_ROOT, runner 진입을 교차 검색하고 호출 인자/환경 및 제품 코드의 잠금 시점을 확인했다. 검색 기록은 `/tmp/527f1409-runner-audit.txt`에 있다.
|
||||
|
||||
| 후보 파일:라인 | 판정/제외 근거 |
|
||||
|---|---|
|
||||
| test_o3_scoped_guard.py:388,472 | checkout 스크립트로 실제 잠금까지 도달. 이번에 tmp marker 지정. |
|
||||
| test_o3_scoped_guard.py:422 | --help로 잠금 전에 종료. |
|
||||
| test_o3_scoped_guard.py:164,437 | 추출 함수/잠금 harness는 tmp_path를 REPO_ROOT 또는 MAM_REAL_ROOT/marker로 지정한다. 기존 marker 동작 검사 유지. |
|
||||
| test_o2_race_free_lock.py:203 | 이미 tmp_path의 경합 marker를 MAM_LOOP_MARKER로 전달. 다른 호출은 loop_lock.sh 자체 또는 bash -n 검사다. |
|
||||
| test_issue5_phase3_loop.py:163,181 | Python/legacy 두 실제 실행 모두 이미 tmp marker 지정. :151은 --help. |
|
||||
| test_loop_cli.py:53,98 | session-check 실행은 tmp marker, planner 실행은 `_loop_env`의 tmp marker 및 임시 control root를 사용. 나머지 오류/도움말은 잠금 전 종료. |
|
||||
| test_issue25_plan_reuse.py:355,365 | 도움말 또는 legacy 옵션 거부. 후자는 이미 tmp marker 지정, marker가 생기지 않음도 검사. |
|
||||
| test_issue21_worktree_prereqs.py:398 | legacy 옵션 거부 실행에 이미 tmp marker 지정. Python runner 검사들은 임시 control root/fixture 사용. |
|
||||
| test_issue21_loop_b.py:234 | checkout의 shell 호출은 임시 WORKSPACE_ROOT와 MAM_LOOP_MARKER를 명시. |
|
||||
| test_issue21_acceptance.py:275,297 | 복사된 임시 main 트리의 shell 실행이며 경합용 임시 marker를 명시. |
|
||||
| test_issue36_prompt_transport.py:532,539 | 임시 ctl의 복사된 스크립트와 ctl 아래 marker를 사용. :630의 추출 harness도 자기 PWD를 root로 지정. |
|
||||
| test_issue5_phase4_runtime.py:142,166,183 | 실제 shell 호출은 모두 --help라 잠금 전에 종료. |
|
||||
| test_o1_rebuttal.py:72,76 | --help 또는 잘못된 --max-rebut 인자로 잠금 전 종료. 나머지는 함수 추출/정적 검사. |
|
||||
| test_b7_diff_untracked.py:174,189 | run_loop는 텍스트 읽기 또는 bash -n. 실제 수행하는 diff_collect는 루프 잠금을 잡지 않는다. |
|
||||
| test_deploy_freshness.py:473 | 텍스트에서 export 구문을 확인하는 정적 검사. |
|
||||
| test_issue5_phase0_contracts.py:481 | 문서/계약 문자열 검사. |
|
||||
| test_tier4_e2e.py:279,285 | mam_sandbox에 복사된 run_loop 실행이므로 잠금 root도 임시 트리다. |
|
||||
| test_issue17_workspace_root_isolation.py:56 | W1은 별도 checkout 복사본에서 선택 테스트를 실행. 새 `.mam` 부재 검사로 빈 디렉터리도 탐지. |
|
||||
| test_loop_marker_isolation.py:34 | L1도 별도 복사본에서 실행하고 그 복사본의 `.mam` 부재를 확인. |
|
||||
|
||||
직접 Python `LoopRunner.run()` 진입도 확인했다. runner.py:1241의 run/_run_body는 shell의 mam_acquire_loop_lock를 호출하지 않는다. shell 진입이 실제 잠금을 획득하고 Python의 release_guard는 현재 PID 소유 marker만 해제하는 구조다. 따라서 Python runner의 단순 인스턴스 생성/메서드 mock 검사들을 shell 잠금 획득 누락으로 분류하지 않았다.
|
||||
|
||||
새 L1 목록은 일부 잠금 이전 거부 경로도 보수적으로 포함한다. 명시적으로 임시 marker를 이미 쓰는 다른 후보가 L1 목록에 전부 들어가야만 격리가 성립하는 구조는 아니며, 해당 제외 사유를 위에서 확인했다. 추가로 수정해야 할 checkout 잠금 획득 지점은 발견하지 못했다.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
요청된 started 명령을 먼저 실행했고 exit 0으로 완료되었다. 정상 heartbeat 절차를 사용했다. 보고서 작성 뒤 요청된 completed 명령을 실행한다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20261003T230658Z-e7866a",
|
||||
"base_sha": "8d26106195a0a24621f140e76389d206332f84c0",
|
||||
"head_sha": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
|
||||
"tree_sha": "a0f1ffede44be7c86267a56b25871e1d081feafb",
|
||||
"review_commit": "17eba0904ebbf54c1d2243ad61a6f2ba17263889",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "52dc1a69",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-10-03T23:35:18Z"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# PR #46 2회차 델타 리뷰 — 52dc1a69
|
||||
|
||||
**PASS**. 이번 한 커밋에서 이전 R1~R3가 해결됐다. 지정 테스트 결과는 아래에 기록한다. 제품 코드 변경은 없으며 새 반려 사유는 발견하지 못했다.
|
||||
|
||||
## 검증 identity와 범위
|
||||
|
||||
- loop: `loop-20261003T230658Z-e7866a`
|
||||
- base: `8d26106195a0a24621f140e76389d206332f84c0`
|
||||
- head: `17eba0904ebbf54c1d2243ad61a6f2ba17263889`
|
||||
- tree: `a0f1ffede44be7c86267a56b25871e1d081feafb`
|
||||
- 검증 cwd: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261003T230658Z-e7866a/1`
|
||||
- interpreter: `/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-46/.venv/bin/python`
|
||||
|
||||
브리프의 HEAD/tree와 clean 상태를 확인했다. `git diff 8d26106 17eba09` 한 커밋만 재검토했다. 변경은 create/resume/stop SKILL.md, JSON 목록, 문서 가드 테스트의 5개 파일(93 insertions, 19 deletions)이다. 제품 스크립트·Python·버전 변경은 없다. `git diff --check 8d26106 HEAD`도 통과했다.
|
||||
|
||||
## 이전 지적 판정
|
||||
|
||||
| 지적 | 판정 | 현재 근거 |
|
||||
|---|---|---|
|
||||
| R1: attach의 격리 서버 선택 유실 | **종결** | `.agents/skills/multi-agent-mux-resume/SKILL.md:30`, `:63`, `:139`가 모두 `herdr --session <server> agent attach <name>`을 안내한다. :63은 child의 export가 caller에 전파되지 않는 점과 native herdr의 환경변수 라우팅 부재를 설명한다. :106에서 row의 서버를 읽고 :109/:126의 get/read에 전달한다. |
|
||||
| R2: dry-run이 아무것도 쓰지 않는다는 설명 | **종결** | resume SKILL.md:73 및 create SKILL.md:164가 spawn·registry 갱신 생략으로 범위를 좁히고 macOS quarantine 속성 제거를 명시한다. 제품의 기존 xattr 호출과 dry-run 반환 순서에 맞는다. |
|
||||
| R3: inventory 갱신으로 삭제 (a) 본문 복귀를 숨길 수 있음 | **종결** | `tests/test_issue42_skill_blocks.py:137`–`:153`이 현재 문서를 직접 파싱해 삭제 sha/fingerprint와 비교한다. `DATA["blocks"]`의 class 선언에 의존하지 않는다. 아래 독립 재삽입 탐침을 거부했다. |
|
||||
| C1: base create의 호출 블록 하나 미추적 | **종결** | `tests/data/skill_code_blocks.json`의 새 `superseded` 항목이 base create SKILL.md:263–266, sha `ceb96a8c1293`, class c/kind invocation, replacement를 기록한다. base 실물과 위치/hash/fingerprint를 대조했다. |
|
||||
| C2: 종료 코드 스캐너의 일반적인 heredoc 한계 | **후속 유지, 비차단** | 이 커밋에서 스캐너를 바꾸지 않았다. 1회차에서도 현재 세 스크립트의 표가 틀렸다고 판정하지 않은 개선 제안이다. 이번 델타의 반려 사유로 재사용하지 않는다. |
|
||||
|
||||
## 독립 검증
|
||||
|
||||
### 서버 라우팅과 Verification 경계 사례
|
||||
|
||||
실제 `/opt/homebrew/bin/herdr --help`를 임시 HOME 환경에서 실행해 전역 `--session` 옵션을 확인했다(서버 접속 없음). 이후 임시 herdr argv 기록 대역을 사용했다. caller의 `HERDR_SESSION_NAME=caller-server`와 무관하게 두 경우 모두 정확히 다음 argv를 전달했다.
|
||||
|
||||
```text
|
||||
native: --session probe-isolated agent attach probe-codex
|
||||
lib.sh shim: --session probe-isolated agent attach probe-codex
|
||||
```
|
||||
|
||||
shim 경로는 실제 lib.sh를 source하되 WORKSPACE_ROOT/HOME/registry를 모두 임시 경로로 두고 MAM_NO_AUTOSTART=1로 실행했다. 실제 herdr 서버나 agent에는 접속하지 않았다. `.agents/skills/lib.sh:255`–`:269`의 명시 `--session` 보존 분기와도 일치한다.
|
||||
|
||||
문서의 SERVER 대입 행을 그대로 추출해 합성 YAML로 확인했다.
|
||||
|
||||
| row 상태 | 실제 결과 |
|
||||
|---|---|
|
||||
| `herdr_session: probe-isolated` | rc 0, SERVER=probe-isolated |
|
||||
| 해당 row는 있지만 `herdr_session` 없음 | rc 0, SERVER=default |
|
||||
| 해당 row 없음 | 대입 rc 1, IndexError, SERVER 비어 있음 |
|
||||
| legacy `herdr_server`만 존재 | rc 0, SERVER=default |
|
||||
|
||||
마지막 두 경우는 Verification 예제의 한계로 남는다. row가 없으면 traceback을 내며 명확히 실패하지만, 블록에 `set -e`/명시적 guard가 없어 그대로 복사 실행한 shell이 후속 조회를 시도할 수 있다. legacy alias만 있는 row는 alias를 읽지 않는다. 다만 이 블록은 **성공한 resume 뒤의 검증**이고, `update_yaml_resumed.sh:140` 및 `:154`–`:156`이 새 row와 기존 legacy row에 `herdr_session`을 채운다. 따라서 정상 resume 경로의 R1은 해결됐으며 이를 새 반려 사유로 삼지 않는다. 이 예제를 실패 복구용 범용 조회로 확대하려면 row 부재 시 즉시 중단하고 `herdr_server` alias도 고려하는 것이 좋다.
|
||||
|
||||
### 삭제 본문 가드와 정상 예제 허용
|
||||
|
||||
1. base `45d1621`에서 `removed` 다섯 개와 `superseded` 하나의 실제 본문을 읽었다. 여섯 항목 모두 base start/end, exact sha, whitespace fingerprint가 데이터와 일치했다.
|
||||
2. 여섯 본문 각각을 원문 및 들여쓰기 변경 형태로 되살려 `_returned_bodies()`가 모두 검출하는 것을 확인했다.
|
||||
3. 1회차와 동일하게 삭제 resume Workflow를 현재 문서 끝에 추가하고 inventory도 정확한 위치/hash 및 class c로 갱신했다. 코드/문서 파일을 수정하지 않고 모듈의 입력과 DATA에 메모리상으로만 적용했다. 기존 inventory 일치와 class 가드는 통과했지만 **새 `test_no_deleted_body_came_back()`는 예상대로 AssertionError**였다.
|
||||
4. 경로·agent·role을 구체적으로 지정한 다른 정상 create 호출 예제는 검출되지 않았다. 부분 명령이나 단어가 겹친다는 이유로 거부하지 않고 전체 본문 identity를 비교한다.
|
||||
|
||||
`fingerprint()`(:132–134)는 whitespace를 하나로 접고 전체 본문을 해시한다. 이로써 들여쓰기/reflow로 동일 본문을 되살리는 경우를 잡는다. 의미를 분석하는 parser는 아니므로 문자열 안 whitespace까지 같게 취급하며, comment 추가 등 모든 의미적 중복을 잡는다고 보장하지 않는다. 현재 여섯 개의 제한된 삭제 identity를 보존하는 가드로는 합당하다. `superseded`의 같은 옛 호출도 되살리지 않는 정책이므로 그 예제를 의도적으로 다시 쓸 때에는 삭제 이력 정책도 함께 재검토해야 한다.
|
||||
|
||||
새 자체 테스트(:156–166)는 helper의 exact/fingerprint 경로를 확인한다. 이름과 달리 DATA를 실제로 갱신하는 테스트는 아니지만 helper가 DATA blocks를 읽지 않고, 이번 독립 탐침이 전체 가드의 inventory 갱신 우회를 직접 검증했다. 이 차이는 현재 동작 결함이 아니다.
|
||||
|
||||
### inventory·보완 문구·범위
|
||||
|
||||
현재 33개 블록의 `(skill, class, kind)`를 이전 JSON과 순서대로 비교해 모두 동일함을 확인했다. resume Verification의 end/sha만 바뀌었고, 기존 removed 항목에 fingerprint, 새 superseded 항목이 추가됐다. 분류 변경은 없다.
|
||||
|
||||
create exit 42는 `MAM_KILL_ON_READY_TIMEOUT=1`일 때 살아 있는 process도 rollback한다는 조건을 추가했고, stop exit 0의 no-op은 purge에는 적용되지 않는다고 좁혔다. 각각 `create_session.sh:392`–`:398`, `stop_session.sh:184`–`:198`과 일치한다. 이 문구 보완도 제품 동작 변경을 요구하지 않는다.
|
||||
|
||||
## 테스트 결과
|
||||
|
||||
**132 passed, 1 skipped in 296.61s (0:04:56)**, 종료 코드 0. skip은 워크트리에 untracked `.claude/skills` 링크가 없는 정상 조건이다. 기존 stop 예제 최소 3개/--agent 가드도 통과했다. 로그: `/tmp/52dc1a69-selected.log`. 테스트 종료 후 git status는 clean이며 HEAD/tree도 브리프와 그대로 일치한다.
|
||||
|
||||
실행 명령(위 검증 cwd):
|
||||
|
||||
```sh
|
||||
AGENT_PYTHON_BIN=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-46/.venv/bin/python PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux-wt/pr-46/.venv/bin/python -m pytest -p no:cacheprovider -q --basetemp=/tmp/52dc1a69-selected tests/test_issue42_skill_blocks.py tests/test_tier2_component.py tests/test_orc_onboard.py tests/test_loop_cli.py tests/test_o1_rebuttal.py tests/test_version_consistency.py
|
||||
```
|
||||
|
||||
독립 탐침: `/tmp/52dc1a69-probes.py`; 결과: `/tmp/52dc1a69-probes.log`. 검증 cwd에서 `PYTHONDONTWRITEBYTECODE=1 <위 Python> /tmp/52dc1a69-probes.py`로 재현한다. 임시 python3 대역은 venv 인터프리터를 exec해 PyYAML을 유지한다. 초기 대역을 단순 symlink로 만들었을 때는 venv 패키지를 찾지 못해 SERVER 검증이 실행되지 않았고, 이를 수정한 최종 실행의 네 결과만 위에 기록했다.
|
||||
|
||||
전체 스위트와 purge 파일은 이번 델타에서 반복하지 않았다. 제품/purge 코드가 그대로이며 1회차의 purge 23/23 통과 결과를 이번 실행 수치에 합산하지 않았다. 실제 `.mam.env`를 읽거나 export하지 않았고, 코드/검증 트리를 수정하지 않았다. 실제 `.mam` 쓰기는 지시된 잡 lifecycle 및 리포트에 한정했다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user