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

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_cwdPath.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 입니다. hasattrgetattr 을 거치므로 __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_DIRimport * 로 새어 나가지 않는 것은 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_dirget_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·:389mqtt_common.LOGS_DIRmqtt_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 → chdirmqtt_common.LOGS_DIR 새 cwd 반영
3 chdirinit_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 만 사용

권장 커밋 분할

  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), AnnAssignast.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 참조는 저장소 내부만 확인했습니다.