- 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
19 KiB
📐 구현 계획서 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__ 구현안에 중복 결함
권고안:
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 회귀가 일어난 상태(전역 재도입)에서 중복이 나타납니다. 그 상황을 디버깅하는 사람에게 혼란을 주므로 집합 기반으로 씁니다.
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 세 번째 가드의 마지막 줄:
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__ 추가)
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
_default_logs_dir()→get_logs_dir()개명 + docstringLOGS_DIR = _default_logs_dir()삭제 (T1)__getattr__추가__dir__추가 (집합 기반) ← C2-a:431Path(logs_dir or LOGS_DIR)→Path(logs_dir or get_logs_dir())(T2):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 에 추가
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 문안에 다음 한 줄을 추가합니다.
- PEP 562 `__dir__` 을 함께 정의해 `dir(mqtt_common)` 및 탭 완성에서 `LOGS_DIR` 이 계속
보이도록 했습니다(`hasattr` 은 `__getattr__` 만으로도 동작하므로 별개입니다).
주의: :5 엣지 카운트와 :70 §2 헤더는 반드시 함께 바꿉니다.
8.2 VERSIONS.md
#### 9 신설. Rev.1 문안에 다음을 추가합니다.
- 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 만 사용 |
권장 커밋 분할
fix(mqtt): resolve the audit-log root per call instead of at import (B-9)— §5.1~5.2test(b9): guard cwd-following, real file placement, env round-trip, global re-binding, and discoverability— §6docs: 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참조는 저장소 내부만 확인했습니다.