Files
multi-agent-mux/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-f380eb54.md
T
Godopu ac82f9b993 fix(mqtt): resolve B-9 by implementing lazy get_logs_dir() evaluation
- Replace import-time LOGS_DIR cwd binding with dynamic get_logs_dir() function
- Implement PEP 562 __getattr__ and __dir__ for transparent LOGS_DIR backward compatibility
- Update audit-log callers in mqtt_common.py and registry.py to use dynamic resolution
- Add 5 regression guards in tests/test_tier1_unit.py (276/276 PASS)
- Update IMPROVEMENTS.md, VERSIONS.md, registry.md, and include plan and peer review reports
2026-08-17 13:37:51 +09:00

390 lines
19 KiB
Markdown

# 📐 구현 계획서 Rev.2 — B-9 (P4-1): `LOGS_DIR` import 시점 cwd 고정 해소
- **Job ID**: `7248c715` (Rev.1 = `f380eb54`)
- **Planner**: claude (session: `herdr:canary-projects-multi-agent-mux-creator-claude`)
- **Role**: Planner (`MULTI_AGENT_RULES.md` §1 — 본 작업에서 저장소 코드 0건 수정)
- **반영 대상 Challenge**: `07b5bd28` (agy, Worker / Plan Reviewer) — `[VERDICT: PASS WITH CHALLENGE]`
- **기준 커밋**: `8cee937` (`refactor`, 작업 트리 clean)
---
## 0. 요약
Challenge 2건을 **실측으로 판정**했습니다. 결과가 갈립니다.
| # | 지적 | 판정 | 근거 |
|---|---|---|---|
| **C1** | macOS `/var``/private/var` 심링크로 §5.1 테스트가 실패 | ⚠️ **일반론은 옳으나 이 테스트에는 미해당 — 결론 기각** | pytest `tmp_path`**이미 resolve 된** `/private/var/…` 를 반환. 실측 `naive == : True` |
| **C1'** | (그럼에도) `realpath` 정규화 적용 | ✅ **채택 — 단, 사유를 정정** | "지금 깨지므로"가 아니라 "pytest 내부 `.resolve()` 에 대한 **암묵적 의존**을 제거하므로" |
| **C2-a** | PEP 562 에 `__dir__()` 동반 정의 | ✅ **채택 — 단, 주장 일부 정정** | `dir()` 에는 영향 있음(False→True). **`hasattr``__dir__` 없이도 True**(실측) |
| **C2-b** | AST 가드에 `ast.AnnAssign` 추가 | ✅ **전면 채택** | `LOGS_DIR: str = …``AnnAssign` 으로 파싱되어 현 가드가 **완전히 놓침**(실측) |
그리고 챌린저의 `__dir__` 구현안 자체에서 **경미한 결함 1건**을 찾았고, Rev.1 가드의 **약한 단언 1건**을 스스로 발견해 보강했습니다.
§1~§4(결함 진단, T1·T2·T3 함정, 설계, 하위 호환 분석)는 챌린저가 §3 표에서 전부 "Proceed as planned" 로 평가했으므로 **변경 없이 유지**합니다.
---
## 1. C1 판정 — 일반론 수용, 결론 기각 (실측)
### 1.1 챌린저의 재현은 유효하다 — 다만 다른 경로다
챌린저는 `tempfile.gettempdir()` 로 재현했습니다. 그 경로는 실제로 미해결 상태입니다.
```
tempfile.gettempdir(): /var/folders/q_/…/T
realpath : /private/var/folders/q_/…/T
differ? : True ← 챌린저 관찰 정확
```
### 1.2 그러나 테스트가 쓰는 `tmp_path` 는 이미 resolve 되어 있다
제안 테스트는 `tempfile` 이 아니라 pytest 의 `tmp_path` 픽스처를 씁니다. 실제 픽스처로 측정한 결과:
```
tmp_path : /private/var/folders/q_/…/T/pytest-of-godopu16/pytest-156/test_c1_symlink_premise0
str(a) : /private/var/folders/…/test_c1_symlink_premise0/a
os.getcwd() : /private/var/folders/…/test_c1_symlink_premise0/a
naive == : True ← Rev.1 테스트는 그대로 통과한다
realpath == : True
```
pytest 의 `TempPathFactory` 는 base temp 를 `.resolve()` 하므로 `tmp_path` 양변이 모두 해결된 상태이고, `os.getcwd()` 도 항상 해결된 경로를 돌려줍니다. **따라서 Rev.1 테스트는 macOS 에서 실패하지 않습니다.**
### 1.3 그럼에도 정규화를 채택하는 이유 (사유 정정)
"지금 깨진다"는 근거는 성립하지 않지만, **채택합니다.** 사유가 다릅니다.
- 현재 통과는 **pytest 내부 구현(`.resolve()`)에 대한 암묵적 의존**입니다. 문서화된 계약이 아닙니다.
- 누군가 나중에 `tempfile.mkdtemp()` 나 심링크된 디렉터리로 바꾸면 조용히 깨집니다 — 그때의 실패 메시지는 B-9 와 무관해 보여 디버깅 비용이 큽니다.
- `os.path.realpath` 는 양변에 붙여도 **비용 0**이고 의존을 제거합니다.
> **부수 확인 — 나머지 가드는 영향 없음**: `test_b9_audit_log_lands_under_the_current_cwd` 는 `Path.exists()` 로 판정합니다. `/var/…` 와 `/private/var/…` 는 같은 대상으로 해석되므로 심링크와 무관합니다(실측 `exists() via tmp_path: True`). 환경변수 가드는 `os.getcwd()` 를 거치지 않아 애초에 무관합니다. **C1 은 문자열 비교 가드 1건에만 해당**하며, 챌린저가 그 범위를 정확히 짚었습니다.
---
## 2. C2 판정 — 채택, 두 곳 정정
### 2.1 `__dir__()` — 채택, 단 `hasattr` 주장은 사실과 다름
실측:
```
no __dir__ : 'LOGS_DIR' in dir() -> False | hasattr -> True | getattr 동작 -> True
with __dir__ : 'LOGS_DIR' in dir() -> True | hasattr -> True
```
- `dir()` 에서 사라지는 것은 **맞습니다**(False→True). 대화형 도구·탭 완성에 영향이 있으므로 채택합니다.
- 그러나 **`hasattr``__dir__` 없이도 True** 입니다. `hasattr``getattr` 을 거치므로 `__getattr__` 만으로 충분합니다. 챌린저 §1-2 의 "`dir()`, `hasattr`, 대화형 도구에서 발견 가능하도록 보장"이라는 서술 중 `hasattr` 부분은 정정이 필요합니다 — 오해하면 "`__dir__` 이 없으면 `hasattr` 이 깨진다"고 읽힙니다.
### 2.2 챌린저의 `__dir__` 구현안에 중복 결함
권고안:
```python
def __dir__():
return sorted(list(globals().keys()) + ["LOGS_DIR"])
```
전역 `LOGS_DIR` 이 되살아난 상태에서 실측:
```
proposed : LOGS_DIR count in dir() = 2 ← 중복
set-based : LOGS_DIR count = 1
```
하필 **T1 회귀가 일어난 상태**(전역 재도입)에서 중복이 나타납니다. 그 상황을 디버깅하는 사람에게 혼란을 주므로 집합 기반으로 씁니다.
```python
def __dir__():
return sorted(set(globals()) | {"LOGS_DIR"})
```
### 2.3 `ast.AnnAssign` — 전면 채택
```
LOGS_DIR: str = "x" → AnnAssign ← ast.Assign 만 검사하면 완전히 놓침
OTHER = 1 → Assign
```
Rev.1 의 AST 가드는 `ast.Assign` 만 순회하므로 **타입 주석이 붙은 전역 재도입을 통과시킵니다.** 지적 그대로 유효합니다.
### 2.4 확인된 비이슈 — `__all__` / `import *`
`__dir__` 도입 시 `from mqtt_common import *` 표면이 걱정될 수 있으나:
- `mqtt_common.py`**`__all__` 정의 0건**
- 저장소 전체에 **`from mqtt_common import *` 0건**
`import *``__all__` 이 없으면 모듈 전역을 열거하며 `__dir__` 을 쓰지 않으므로, 어느 쪽으로도 영향이 없습니다. (`LOGS_DIR``import *` 로 새어 나가지 않는 것은 Rev.1 §4 의 from-import 분석과 같은 결론입니다.)
---
## 3. 🆕 Rev.2 자체 발견 — 환경변수 가드의 약한 단언
Rev.1 §5.1 세 번째 가드의 마지막 줄:
```python
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR")
assert "/tmp/b9-override" != mq.get_logs_dir() # ← 부등호 단언
```
부등호는 **거의 모든 오동작을 통과시킵니다.** `get_logs_dir()` 가 빈 문자열이나 `None`, 엉뚱한 경로를 반환해도 `"/tmp/b9-override"` 와 다르기만 하면 통과합니다. 실제로 검증해야 할 것은 "환경변수를 지우면 **cwd 기반 기본값으로 돌아온다**"입니다. Rev.2 에서 등호 단언으로 교체했습니다(§5.1).
---
## 4. 설계 (Rev.1 유지 + `__dir__` 추가)
```python
def get_logs_dir() -> str:
"""Audit-log root, resolved at call time (B-9).
Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
``<cwd>/.mam/delegate_job_logs``. Resolved per call rather than at import
so a chdir after import cannot strand the audit trail in the old tree —
the same reason ``DEFAULT_REGISTRY_DIR`` stays a relative string.
"""
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
if env and env.strip():
return env
return os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
def __getattr__(name: str): # PEP 562 (3.7+)
"""Keep ``mqtt_common.LOGS_DIR`` working for external consumers
(documented in registry.md) while resolving it dynamically."""
if name == "LOGS_DIR":
return get_logs_dir()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__(): # PEP 562 권장 — dir()/탭 완성 유지
return sorted(set(globals()) | {"LOGS_DIR"})
```
`_default_logs_dir``get_logs_dir` 개명, 모듈 전역 `LOGS_DIR = …` 대입 **삭제**.
### 4.1 구현 함정 3종 (Rev.1 §2 유지 — 전부 실측)
| # | 함정 | 실측 |
|---|---|---|
| **T1** | 전역을 남기면 `__getattr__`**호출조차 안 됨** | 수정 후에도 chdir 시 stale |
| **T2** | 모듈 **내부** 맨이름 `LOGS_DIR``__getattr__` 대상 아님 | `NameError` |
| **T3** | 그 `NameError` 를 best-effort `except Exception`**삼킴** | `logger.warning` 만 남고 정상 반환 → 무음 로그 소실 + 전 테스트 통과 |
T3 때문에 가드 하나는 **반드시 실제 파일 생성**을 단언해야 합니다.
---
## 5. 구현 계획
### 5.1 단계 1 — `mqtt_common.py`
1. `_default_logs_dir()``get_logs_dir()` 개명 + docstring
2. **`LOGS_DIR = _default_logs_dir()` 삭제** (T1)
3. `__getattr__` 추가
4. **`__dir__` 추가 (집합 기반)** ← C2-a
5. `:431` `Path(logs_dir or LOGS_DIR)``Path(logs_dir or get_logs_dir())` (T2)
6. `:579` 동일 교체 (T2)
### 5.2 단계 2 — `registry.py`
`:198`·`:389``mqtt_common.LOGS_DIR``mqtt_common.get_logs_dir()`.
### 5.3 단계 3 — `registry.md`
`:168` 헬퍼 목록에 `get_logs_dir` 추가, `LOGS_DIR` 이 동적 호환 별칭임을 1줄 명시. `BOOTSTRAP*.md` 는 동작 무변경이므로 손대지 않습니다.
---
## 6. 회귀 가드 (확정)
### 6.1 `tests/test_tier1_unit.py` 에 추가
```python
def test_b9_logs_dir_follows_cwd_changes(mam_sandbox, tmp_path, monkeypatch):
"""B-9: the audit-log root must be resolved per call, not frozen at import."""
mq = get_mqtt_common(mam_sandbox)
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
a = tmp_path / "a"; b = tmp_path / "b"
a.mkdir(); b.mkdir()
# realpath on both sides: pytest's tmp_path happens to be pre-resolved today,
# but relying on that is an undocumented dependency (C1').
def logs_under(p):
return os.path.realpath(os.path.join(str(p), ".mam", "delegate_job_logs"))
monkeypatch.chdir(a)
assert os.path.realpath(mq.get_logs_dir()) == logs_under(a)
monkeypatch.chdir(b)
assert os.path.realpath(mq.get_logs_dir()) == logs_under(b)
# the compat alias must follow too (T1: a surviving global fails here)
assert os.path.realpath(mq.LOGS_DIR) == logs_under(b)
def test_b9_audit_log_lands_under_the_current_cwd(mam_sandbox, tmp_path, monkeypatch):
"""B-9/T3: assert the FILE appears — a swallowed NameError must not pass."""
mq = get_mqtt_common(mam_sandbox)
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
monkeypatch.chdir(tmp_path)
mq.init_job_log("b9job", {"status": "pending"})
assert (tmp_path / ".mam" / "delegate_job_logs" / "b9job" / "meta.json").exists(), \
"audit log did not land under the current cwd (the best-effort handler may have swallowed an error)"
def test_b9_logs_dir_env_override_is_dynamic(mam_sandbox, tmp_path, monkeypatch):
"""B-9: DELEGATE_JOB_LOGS_DIR must be honoured at call time, both ways."""
mq = get_mqtt_common(mam_sandbox)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", "/tmp/b9-override")
assert mq.get_logs_dir() == "/tmp/b9-override"
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR")
# equality, not inequality — clearing the env must restore the cwd default (Rev.2 §3)
assert os.path.realpath(mq.get_logs_dir()) == \
os.path.realpath(os.path.join(str(tmp_path), ".mam", "delegate_job_logs"))
def test_b9_no_module_level_logs_dir_binding():
"""B-9/T1: a surviving module global would make __getattr__ dead code."""
import ast, pathlib
src = (pathlib.Path(__file__).resolve().parent.parent / ".agents" / "skills"
/ "multi-agent-mux-delegate-job" / "scripts" / "mqtt_common.py")
tree = ast.parse(src.read_text())
for node in tree.body: # module scope only
if isinstance(node, ast.Assign):
for t in node.targets:
assert not (isinstance(t, ast.Name) and t.id == "LOGS_DIR"), \
f"line {node.lineno}: module-level LOGS_DIR binding shadows __getattr__ (B-9/T1)"
elif isinstance(node, ast.AnnAssign): # C2-b: LOGS_DIR: str = ... parses as AnnAssign
assert not (isinstance(node.target, ast.Name) and node.target.id == "LOGS_DIR"), \
f"line {node.lineno}: annotated module-level LOGS_DIR binding shadows __getattr__ (B-9/T1)"
def test_b9_logs_dir_stays_discoverable(mam_sandbox):
"""B-9/C2-a: PEP 562 __dir__ keeps LOGS_DIR visible to dir() and tooling."""
mq = get_mqtt_common(mam_sandbox)
assert "LOGS_DIR" in dir(mq)
assert hasattr(mq, "LOGS_DIR") # true via __getattr__ even without __dir__
assert dir(mq).count("LOGS_DIR") == 1 # set-based __dir__ must not duplicate
```
**Rev.1 대비 변경**
| # | 변경 | 근거 |
|---|---|---|
| 1 | 가드 1 을 `os.path.realpath` 양변 정규화로 교체 | C1' |
| 2 | 가드 3 의 마지막 단언을 부등호 → **등호** | Rev.2 §3 |
| 3 | 가드 4 에 `ast.AnnAssign` 분기 추가 | C2-b |
| 4 | **가드 5 신설** (`dir()` 가시성 + 중복 없음) | C2-a, §2.2 |
가드는 4종 → **5종**입니다.
### 6.2 뮤테이션 검증 (구현자 필수)
| # | 뮤테이션 | 기대 |
|---|---|---|
| **M1** | `LOGS_DIR = get_logs_dir()` 전역 되살림 (T1) | 가드 1·4 **FAIL** |
| **M1b** | `LOGS_DIR: str = get_logs_dir()` 로 되살림 (C2-b) | 가드 4 **FAIL** ← Rev.1 가드로는 통과했을 케이스 |
| **M2** | `:431` 을 맨이름 `LOGS_DIR` 로 되돌림 (T2·T3) | 가드 2 **FAIL** (가드 1·3 은 통과 — T3 무음성 증명) |
| **M3** | `get_logs_dir()` 내부를 모듈 로드 시 계산값으로 대체 | 가드 1 **FAIL** |
| **M4** | `__getattr__` 삭제 | 가드 1·5 **FAIL** |
| **M5** | `__dir__` 삭제 | 가드 5 **FAIL** (`hasattr` 은 여전히 통과 — §2.1 의 구분을 증명) |
**M2 가 여전히 핵심**입니다. **M1b·M5 는 이번 라운드에서 추가**된 것으로 각각 C2-b·C2-a 에 대응합니다. 전부 기대대로 FAIL 하지 않으면 가드가 아닙니다.
---
## 7. 검증 절차
| # | 확인 | 기대 |
|---|---|---|
| 1 | `python -c "import mqtt_common"` | OK |
| 2 | import → `chdir``mqtt_common.LOGS_DIR` | 새 cwd 반영 |
| 3 | `chdir``init_job_log` → 파일 위치 | **새 cwd 아래 생성** (T3 — 핵심) |
| 4 | `grep -n "^LOGS_DIR" mqtt_common.py` | 0건 (T1) |
| 5 | `grep -n "or LOGS_DIR" mqtt_common.py` | 0건 (T2) |
| 6 | `python -c "import mqtt_common as m; print('LOGS_DIR' in dir(m), dir(m).count('LOGS_DIR'))"` | `True 1` (C2-a) |
| 7 | `DELEGATE_JOB_LOGS_DIR` 설정/해제 | 즉시 반영, 해제 시 cwd 기본값 복귀 |
| 8 | `registry.py logs --list` | 회귀 없음 |
| 9 | **뮤테이션 M1·M1b·M2·M3·M4·M5** | 각각 기대대로 FAIL |
| 10 | `pytest tests/ -q` | **276 passed** (271 실측 + 가드 5건) |
| 11 | `env -u PYTHONPATH pytest tests/test_tier1_unit.py -q` | 통과 (환경 비의존) |
| 12 | `IMPROVEMENTS.md` `:5``:70` / `:6``:85` 대조 | 각각 일치 |
10번은 약 7분 소요됩니다. 백그라운드 실행 권장.
---
## 8. 문서 동기화
### 8.1 `IMPROVEMENTS.md` — 7곳
| 행 | 현재 | 변경 후 |
|---|---|---|
| `:3` | 최종 갱신일 (… 271/271) | B-9 완료 및 **276/276** 반영 |
| `:5` | 미해결 **2건** (아키 1, **엣지 1**) | 미해결 **1건** (아키 1, **엣지 0**) |
| `:6` | 완료 **23건** | 완료 **24건**, 목록에 `B-9` 추가 |
| `:70` | `## 2. … (Edge-case Bugs — 1건)` | `… (Edge-case Bugs — 0건 — 전원 완료)` |
| `:72-73` | B-9 항목 | **삭제** (§5 로 이동) |
| `:85` | `## 5. … (Completed Tasks — 23건)` | `… (Completed Tasks — 24건)` |
| `:251` | 로드맵 P4-1 행 | `… **(✅ 완료 — 전체 276/276 PASS)**` |
§5 신규 항목 — Rev.1 문안에 다음 한 줄을 추가합니다.
```markdown
- PEP 562 `__dir__` 을 함께 정의해 `dir(mqtt_common)` 및 탭 완성에서 `LOGS_DIR` 이 계속
보이도록 했습니다(`hasattr``__getattr__` 만으로도 동작하므로 별개입니다).
```
**주의**: `:5` 엣지 카운트와 `:70` §2 헤더는 **반드시 함께** 바꿉니다.
### 8.2 `VERSIONS.md`
`#### 9` 신설. Rev.1 문안에 다음을 추가합니다.
```markdown
- PEP 562 `__dir__` 병행 정의로 `dir()`·탭 완성 가시성 유지.
```
전체 회귀 수치는 **276** 으로 기재합니다.
---
## 9. 규모 및 리스크
| 파일 | 변경 |
|---|---|
| `mqtt_common.py` | 함수 개명 + docstring, 전역 삭제, `__getattr__`·`__dir__` 추가(~9줄), 소비자 2곳 |
| `registry.py` | 2곳 |
| `registry.md` | 1~3줄 |
| `tests/test_tier1_unit.py` | 가드 **5건** |
| `IMPROVEMENTS.md` / `VERSIONS.md` | 카운트·항목 이동 + changelog |
| **테스트 총계** | 271 (실측) → **276** |
| 리스크 | 평가 |
|---|---|
| **T1 — 전역 잔존으로 수정 무효** | 🔴 가드 1·4 + M1·**M1b**. AST 가드가 주석 대입까지 덮음 |
| **T3 — 무음 로그 소실** | 🔴 가드 2(파일 존재) + M2. 문자열 단언만으로는 못 잡음 |
| 가드 1 의 플랫폼 의존 | `realpath` 정규화로 제거 (C1') |
| `__dir__` 도입 부작용 | `__all__` 없음·`import *` 0건 확인 → 영향 없음 |
| 성능 | `get_logs_dir()``getcwd` 1회 + `join`. 이미 파일 I/O 하는 경로 — 무시 가능 |
| 환경변수 동적 반영 | 의도된 개선. 완료 노트에 명시 |
| 테스트 간 cwd 누수 | 가드는 `monkeypatch.chdir` 만 사용 |
### 권장 커밋 분할
1. `fix(mqtt): resolve the audit-log root per call instead of at import (B-9)` — §5.1~5.2
2. `test(b9): guard cwd-following, real file placement, env round-trip, global re-binding, and discoverability` — §6
3. `docs: sync registry.md, IMPROVEMENTS.md and VERSIONS.md for B-9` — §5.3 + §8
---
## 10. 한계
- 본 계획은 Planner 산출물이며 **저장소 파일을 수정하지 않았습니다**(작업 트리 계획 전후 clean). 프로토타입은 `$TMPDIR` 에서 수행 후 삭제했습니다.
- **이번 라운드에 실측한 것**: pytest `tmp_path` 가 이미 resolve 된 경로를 반환(C1 결론 기각), `tempfile.gettempdir()` 는 미해결(챌린저 재현 자체는 유효), 파일 존재 가드는 심링크와 무관, `__dir__` 유무에 따른 `dir()` 차이와 **`hasattr` 은 무관**함, 챌린저 `__dir__` 안의 중복(count=2), `AnnAssign``ast.Assign` 검사를 우회함, `__all__`·`import *` 부재.
- **여전히 실행 검증하지 않은 것**: §6 의 가드 코드는 실행하지 않았습니다. §4 설계는 축소 프로토타입으로만 확인했고 실제 `mqtt_common.py` 에 적용해 보지 않았습니다. §6.2 뮤테이션이 그 대체 절차입니다.
- pytest `tmp_path` 의 사전 resolve 는 **관측된 동작**이며 문서화된 계약은 아닙니다 — 그래서 정규화를 채택했습니다.
- 베이스라인은 `8cee937` 에서 **271 passed in 430.64s** 실측(`IMPROVEMENTS.md:3` 과 일치). §7-10 의 276 은 가드 5건을 더한 값입니다.
- `registry.md` 외 외부 문서의 `LOGS_DIR` 참조는 저장소 내부만 확인했습니다.