45 KiB
📑 Issue #5 Phase 1 구현 계획서 (Rev.2) — lib_py/delegation/ 추출 및 장수 MQTT 연결 프로토타입
- Job ID:
9aae4233(Rev.1:923e9403) / Planner:planner-reviewer-claude-01 - 반영한 이의제기:
creator-claude-01의 Challenge (Jobc0b9c987) — D2 shim 부트스트랩의 컨텍스트 충돌 - Issue: Gitea Issue #5 — Phase 1 (
implementation_plan.mdRev.4 §2 Phase 1 행,task.mdP1-01~P1-03) - 기준 커밋:
fc0bd96(branchrefactor, working tree clean) / Python3.9.6/ paho-mqtt2.1.0/ herdr0.8.2/ 전체 테스트 수집 510 - 문서 성격: Worker가 그대로 실행할 수 있는 단계별 구현 명세. 모든 인용 행 번호는 라이브 코드로 직접 확인함(§1). Rev.1 대비 변경은 §0-1에 모아 두었고, 본문에는 이미 반영되어 있다.
0. 요약 (TL;DR)
- 추출 대상은
lib.shL1833–1941의 위임 블록 5개 함수뿐이다. L1943 이후(wait_for_tui_ready,send_keys_safe등)는 TUI 조작 헬퍼이며 Phase 1 비범위다. scripts/{mqtt_common,registry,publish_event,job_subscriber}.py의 파일 경로는 와이어 계약이다. 에이전트에게 주입되는 지시문(create_session.sh:483, 래퍼:197/:318), SKILL.md, BOOTSTRAP*.md,deploy/install.sh:103, 테스트 3종이 리터럴 경로로 호출한다. 구현체는lib_py/delegation/으로 옮기되scripts/*.py는 동일 모듈 객체를 가리키는 호환 shim으로 남긴다.- shim은
sys.path를 건드리지 않고lib_py.delegation패키지만 자기 옆 트리에 "프로세스당 1회" 바인딩한다(Rev.2, D2). 이의제기가 실증한 대로sys.path.insert(0, ...)방식은 pytest에서 위임과 무관한lib_py.*까지 임시 사본으로 가로챈다. 이의제기가 제안한 "잎(leaf) 모듈 파일 직접 적재"도 재현 결과 비-잎 모듈(publish_event)에서 두 트리가 섞이므로 채택하지 않고, 패키지 단위 bind-once로 확정했다(§1-8 근거). - 현재 셸 헬퍼 3종을 직접 검증하는 테스트는 0개다. 이동 전에 특성화(golden) 테스트를 먼저 추가한다(P1-00).
- 장수 연결 프로토타입은 라이브러리 전용이다. CLI는 프로세스 단위로 종료되므로 재사용 이득이 없다. 벤치마크는 결정적 가짜 전송 + 실브로커(정보성) 2모드.
- 자동완료 정책은 3개 표면에서 한 줄도 바뀌지 않는다. 테스트로 고정한다.
0-1. Rev.1 → Rev.2 변경 내역 (이의제기 반영)
| 이의제기 항목 | Planner 독립 검증 결과 | 계획 변경 |
|---|---|---|
§1 D2 sys.path.insert(0)이 pytest에서 lib_py 전체를 샌드박스로 고정 |
재현 확인 (§1-8 표, 변형 d2_insert0 시나리오 A: lib_py.agents가 sandbox에서 로드) |
D2 전면 교체 |
§2-1 정체성 단정(is)이 "같은 트리"를 증명하지 못함 |
동의. 시나리오 A에서 두 참조가 모두 sandbox인데 단정은 True |
D2-2 출처(provenance) 단정 PV-1~PV-4 추가, G4 재정의 |
| §2-2 R3 전제("실제 트리에서만 로드")가 D2와 모순 | 동의 | R3을 "수용"에서 "해소"로 재작성 |
§3 append로 바꾸면 freeze가 깨짐 |
동의. 본 세션 PYTHONPATH=라이브 트리, 실행 명령=freeze 경로, run_loop.sh에 PYTHONPATH 처리 0건 모두 재확인 |
D2-1 컨텍스트 우선순위 표 신설 |
§5-1 "옆 파일 직접 적재(spec_from_file_location) + 다르면 교체" |
부분 채택. 잎 모듈 단독 임포트에서는 동작하나, publish_event만 임포트되는 실제 에이전트 호출 형태(C2_pe)에서 publish_event=freeze, mqtt_common=live로 혼재하고 정체성 단정이 False가 됨. 진짜 standalone(C1)에서는 부모 패키지 부재로 ModuleNotFoundError. 두 샌드박스가 섞이는 시나리오(D)에서도 혼재 |
적재 단위를 모듈 → 패키지로 올리고, "가장 가까운 사본으로 교체"가 아니라 "프로세스당 최초 바인딩 유지(bind-once)" 로 규칙 변경. standalone은 lib_py 부재 시에만 sys.path.append 폴백 |
| §5-2 출처 단정 병기 | 채택 | PV-1~PV-4 |
| §5-3 컨텍스트 4종 우선순위 표 + 4종 전부 테스트 | 채택 (6 컨텍스트로 확장) | D2-1, P1-01 테스트 |
| §5-4 세션 위생 테스트 | 부분 채택. "tmp 경로가 sys.path에 없음"은 현행 test_tier1_unit.py:23이 샌드박스 scripts 디렉터리를 영구 삽입하므로 이미 실패함. 조건을 "lib_py 디렉터리를 포함하는 tmp 경로가 없음"으로 정밀화 |
PV-3 |
| §6 D6·설치 경로 안전 확인 | 동의. mqtt_common.py의 os.environ 참조는 전부 함수 내부(:139,:196,:203,:225-233), 모듈 상수(:117-128) env 무관, 나머지 3개 스크립트 os.environ 0건, install.sh:127/131/135 트리 통째 복사, install_mam.sh:126 .agents/ rsync 재확인 |
변경 없음 (P1-04의 core_files 추가는 무결성 목록 갱신 목적임을 명시) |
1. Planner 사전 실측 (라이브 검증 결과)
1-1. 추출 범위 경계 (lib.sh, 총 2312행)
| 행 | 함수 | 분류 | 비고 |
|---|---|---|---|
| 1833 | 섹션 주석 multi-agent-mux-delegate-job integration helpers |
경계 시작 | |
| 1841 | _delegate_py_bin |
셸 유지 | 파이썬 인터프리터 자체를 찾는 부트스트랩. L1598(env_python), L1660(atomic_dump_yaml), reconcile.sh:67, create_session.sh:191/193/215/482, stop_session.sh:199, resume_session.sh:116/126에서도 사용. tests/test_c1_tui_readiness.py:168,513이 mock으로 덮어씀 |
| 1865 | _delegate_script |
셸 유지 | 스크립트 경로 해석. shim이 남으므로 그대로 동작 |
| 1876 | delegate_submit_job |
추출 | registry.py register 호출, stdout에 JID만 출력 |
| 1897 | delegate_publish_event |
추출 | 비치명 계약: 빈 job_id/스크립트 부재/브로커 실패 모두 return 0 |
| 1912 | start_watchdog |
추출 → ensure_subscriber_daemon |
pgrep -f "bash <monitor_script> --subscribe" 선검사 후 nohup bash reconcile.sh --subscribe --idle-timeout 0 스폰, PID를 stdout 출력. 스크립트 부재 시 stderr ERROR: monitor script not found: ... + return 1 |
| 1943~ | wait_for_tui_ready, inject_instructions, _pane_*, send_keys_safe, handle_startup_dialogs |
비범위 | TUI 조작 |
lib_py는 lib.sh:25(SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 직후 export PYTHONPATH="$SKILL_DIR:...") 및 :164로 임포트 가능해진다. 즉 어느 트리의 lib.sh를 source 했느냐가 그 셸 프로세스의 lib_py 트리를 결정한다. env_python(L1583)은 PYTHONPATH=$SKILL_DIR를 명시 전달하는 선례.
1-2. 셸 헬퍼 호출자 (라이브 트리)
| 호출자 | 헬퍼 | 계약상 의존 |
|---|---|---|
create_session.sh:345 |
delegate_submit_job |
$( )로 stdout 캡처 → JID |
create_session.sh:497,501 |
delegate_publish_event error/started |
비치명 |
create_session.sh:504-505 |
start_watchdog "$JOB" "$WORKSPACE" |
$( )로 PID 캡처 후 echo만 함(파싱 없음) |
stop_session.sh:193,242,322 |
delegate_publish_event progress/cancelled |
implementation_plan.md §3-2가 보존 요구 |
update_yaml_resumed.sh:257 |
delegate_publish_event progress "resumed" |
비치명 |
run_loop.sh:144-155 |
(헬퍼 미사용) 래퍼 multi-agent-mux-delegate-job를 직접 실행 |
Phase 1 무관 |
1-3. 파이썬 표면과 리터럴 경로 계약
- 스크립트 4종:
mqtt_common.py(670행),registry.py(426행),publish_event.py(239행),job_subscriber.py(339행). 서로를import mqtt_common/import registry처럼 최상위 이름으로 임포트한다. 다른lib_py모듈은 하나도 임포트하지 않는다(grep -n lib_py scripts/*.py→ 0건; D2의 전제). - 리터럴 경로 호출자: 래퍼 12개소(
:136,166,197,277,287,318,355,394,400,544,550,571,575,583,586),create_session.sh:483,SKILL.md:78,BOOTSTRAP.md/BOOTSTRAP.ko.md:158-180,README.md:211/README.ko.md:194,MESSAGING.md:399,deploy/install.sh:103,reconcile.sh:98-113(임베디드 구독자가 scripts 디렉터리를sys.path에 append 후import mqtt_common, registry). - 테스트 임포트 방식:
tests/test_tier1_unit.py:23,571-820과tests/test_tier2_component.py:25는 샌드박스 scripts 디렉터리를sys.path에 넣고import registry, publish_event, job_subscriber, mqtt_common;tests/test_tier4_e2e.py:414는publish_event.py를 서브프로세스로 실행.tests/conftest.py:10-12는 실제 skills 디렉터리를sys.path[0]에 넣고,mam_sandbox는.agents/skills전체를tmp_path/skills와tmp_path/.agents/skills로 복사한다(이동 후 사본에도lib_py/delegation/이 포함됨). - 본 job과 Rev.1 job의 위임 메시지 자체가
.../mam-loop-freeze.PfAQNj/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py경로를 사용한다. 이때 에이전트 세션의PYTHONPATH는 라이브 트리다(실측:/Users/.../multi-agent-mux/.agents/skills:...).run_loop.sh에는PYTHONPATH처리가 없고(grep0건):14-17에서 자기 위치 기준REPO_ROOT의lib.sh를 source 하므로 루프 프로세스 자체는 freezelib.sh가 export한 freezePYTHONPATH를 갖는다.
1-4. 기존 테스트 커버리지 실측
| 대상 | 커버 테스트 | 비고 |
|---|---|---|
publish_event 실패 시 status/감사로그/seq 소비 |
test_tier1_unit.py G-1~G-4 (:568-698) |
127.0.0.1:65432 비라우팅 포트, 네트워크 미접촉 |
job_subscriber 디스크 폴백/exit 0·1·3 |
G-5~G-9 (:700-836) | |
| 래퍼 rc=3 매핑 리터럴 | G-10 (:838) | 래퍼 텍스트를 건드리면 깨짐 |
| HMAC/retry/topic/reason_code | test_mqtt_* (:339-420) |
|
| seq 단조 증가, 모니터 중복 잠금 | test_tier2_component.py:1082, :1007 |
|
delegate_submit_job / delegate_publish_event / start_watchdog |
없음 (0건) |
빠른 회귀 부분집합(G-1~G-10 + mqtt + seq + monitor lock): 18 passed in 1.74s. 이 부분집합의 파일들은 lib_py를 모듈 레벨에서 임포트하지 않으므로(test_tier1_unit.py의 lib_py 사용은 :848,:873,:902,:947,:978,:1037,:1072 전부 함수 내부) 이의제기 §4의 지적대로 "샌드박스 shim이 먼저 임포트되는" 시나리오 A에 해당한다 — Rev.2 D2는 이 경우에도 lib_py.agents 등을 가로채지 않는다(§1-8).
1-5. 자동완료 정책이 구현된 3개 표면 (Phase 1 불변)
publish_event.py:51-56EVENT_TO_STATUS및:227의update_job_status동기화.- 래퍼
:335-348구독자 exit code →job_status매핑 및:394/:400의registry.py status --set completed|error. reconcile.sh:125-168_MUTATION+handle_terminal.
run_loop.sh:238-276 wait_for_job은 .mam/jobs/<id>.json의 status를 3초 간격 폴링할 뿐이므로 영향 없음.
1-6. 장수 연결의 실측 동기
Rev.1 job의 started 이벤트: payload timestamp 05:58:49Z → attempt 1/3 failed: no CONNACK from broker (05:59:00Z) → 모니터 수신 05:59:01.487Z. 1회 발행 약 12.5초, 원인은 CONNECT_ACK_TIMEOUT=10(publish_event.py:58)의 1회 실패 + 재연결. Rev.1 completed 발행과 본 job의 started 발행에서도 동일한 1회 CONNACK 실패가 재현됐다.
1-7. 환경 제약
- 로컬 브로커 없음(
mosquitto,nats-server,docker미설치). 벤치마크 실브로커 모드는 정보성. mqtt_common.py:112에서_load_dotenv()가 임포트 시점에 실행되어.mam.env(존재, 0600)를 주입. 루트 탐색은__file__기준 상향(.agents또는.git).- B-13 freeze(
run_loop.sh:112-126)는.agents/skills전체를cp -R하므로lib_py/delegation/은 자동 포함(현재 freeze에lib_py/존재 확인). baseline.md§5-2VALID_STATUSES인용은 커밋19d798b에서registry.py:38로 정정 완료(baseline.md:93).
1-8. 이의제기 재현 및 대안 비교 (Rev.2 신규)
세 shim 변형을 real/sandbox/sandbox2/freeze 네 트리(각 lib_py/{__init__,agents/__init__,delegation/{__init__,mqtt_common,publish_event}}.py + scripts/ shim)에 배치하고, env -u PYTHONPATH의 새 인터프리터에서 시나리오별로 실행했다. publish_event는 from lib_py.delegation import mqtt_common을 포함해 비-잎 모듈을 모델링한다. 열: 어느 트리에서 로드됐는가.
| 시나리오 (첫 임포트 → 이후) | d2_insert0 (Rev.1) |
worker_leaf_file (이의제기 §5-1) |
pkg_bind_once (Rev.2 채택) |
|---|---|---|---|
| A pytest: real on path → sandbox shim(mc, pe) | mc=sb, pe=sb, agents=sb ✗ | mc=sb, pe=sb, agents=real | mc=sb, pe=sb, agents=real ✓ |
| A_pe pytest: sandbox shim(pe만) | agents=sb ✗ | pe=sb, pe.dep=real, identity=False ✗ | pe=sb, pe.dep=sb, identity=True ✓ |
B pytest: real lib_py.agents 먼저 → sandbox shim |
전부 real | mc=sb, pe=sb, agents=real | mc=sb, pe=sb, agents=real ✓ |
B2 pytest: real lib_py.delegation 먼저 → sandbox shim |
(미측정) | alias=sb, pkg=real, identity=False ✗ | 전부 real, identity=True ✓ |
C1 standalone(PYTHONPATH 없음): freeze shim |
전부 freeze | ModuleNotFoundError: lib_py ✗ (비-잎 모듈의 내부 임포트가 부모 패키지를 요구) |
전부 freeze ✓ (폴백 append) |
C2 freeze shim + PYTHONPATH=live (mc, pe) |
전부 freeze | 전부 freeze, agents=real | 전부 freeze, agents=real ✓ |
C2_pe freeze shim(pe만) + PYTHONPATH=live — 실제 에이전트 호출 형태 |
전부 freeze | pe=freeze, pe.dep=real, identity=False ✗ (B-13 부분 상실) | pe=freeze, pe.dep=freeze ✓ |
| D 두 샌드박스: sb shim(mc) → sb2 shim(pe) | agents=sb ✗ | pe=sb2, pe.dep=sb 혼재 ✗ | 전부 sb (최초 바인딩 유지) ✓ |
E sandbox shim(mc) → 이후 from lib_py.delegation import publish_event 직접 임포트 |
(해당 없음) | 직접 임포트=real, shim=sb 혼재 | 둘 다 sb ✓ |
결론: 이의제기의 결함 지적은 정확하고, 제안한 적재 방식은 "잎 모듈을 먼저 임포트하는" 경우에만 성립한다. 결정 기준은 (i) lib_py.delegation.*가 한 프로세스 안에서 항상 단일 트리일 것, (ii) lib_py의 다른 서브패키지는 shim이 절대 건드리지 않을 것, (iii) sys.path 무변경(standalone 폴백 제외) — 이를 모두 만족하는 것은 패키지 단위 bind-once뿐이다. 재현 하니스는 tests/fixtures/issue_5/phase1/shim_contexts.py로 그대로 옮겨 P1-01 테스트에 사용한다(부록 B).
2. 설계 결정 (Design Decisions)
D1. 패키지 배치 — 기존 이름 보존, 신규 추가
.agents/skills/lib_py/delegation/
__init__.py # 공개 API 재수출: DelegationService, ensure_subscriber_daemon, PersistentPublisher
mqtt_common.py # git mv (scripts/mqtt_common.py) — 내용 변경은 D6·D8 항목만
registry.py # git mv (scripts/registry.py)
publish_event.py # git mv (scripts/publish_event.py) — D7의 함수 분리만
job_subscriber.py # git mv (scripts/job_subscriber.py)
service.py # DelegationService 파사드 (신규)
daemon.py # ensure_subscriber_daemon (신규, start_watchdog 이식)
publisher.py # OneShotPublisher / PersistentPublisher (신규)
cli.py # python -m lib_py.delegation.cli {submit|publish|ensure-daemon} (신규)
__main__.py # cli.main 위임 (신규)
bench.py # 벤치마크 하니스 (신규)
git mv로 이동해 이력을 보존한다. 이동한 4개 모듈 내부의import mqtt_common/import registry/from mqtt_common import ...는 전부from lib_py.delegation import mqtt_common, registry형태로 바꾼다.- 패키지 내부 임포트 규칙(테스트로 grep 고정): (a) 최상위 이름(
import registry등) 금지 —lib_py.agents.registry와의 혼동 차단; (b)lib_py.delegation밖의 어떤lib_py모듈도 임포트 금지(paths,state,atomic_yaml,agents,layout,workspace_uuid) — D2에서 부모lib_py와lib_py.delegation이 서로 다른 트리일 수 있으므로(C2), 형제 모듈을 임포트하면 트리가 섞인다. 현재 스크립트는 이미 이 규칙을 만족한다(§1-3). - Python 3.9 호환 필수:
match, PEP 604 런타임 유니온,zip(strict=)금지. 게이트에서compileall.
D2. scripts/*.py shim — lib_py.delegation 패키지를 자기 옆 트리에 프로세스당 1회 바인딩 (Rev.2)
4개 shim은 아래 블록을 바이트 동일하게 포함한다({mod}만 다름). 공유 헬퍼 파일로 빼지 않는 이유: import _bootstrap 같은 최상위 이름이 첫 샌드박스에 캐시되어 다른 트리의 shim이 남의 헬퍼를 쓰게 되기 때문이다. 동일성은 테스트로 고정한다.
#!/usr/bin/env python3
"""Compatibility shim — implementation lives in lib_py.delegation.{mod}.
Binds the lib_py.delegation *package* to the tree next to this file, once per
process, without touching sys.path. See docs/contracts/issue-5/... (D2)."""
import importlib, importlib.util, os, sys
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_PKG = "lib_py.delegation"
if _PKG not in sys.modules: # bind once per process; never rebind
try:
import lib_py # parent resolved normally; shims never rebind it
except ModuleNotFoundError:
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
import lib_py
_d = os.path.join(_SKILLS, "lib_py", "delegation")
_spec = importlib.util.spec_from_file_location(
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
_pkg = importlib.util.module_from_spec(_spec)
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
_spec.loader.exec_module(_pkg)
lib_py.delegation = _pkg
_impl = importlib.import_module(_PKG + ".{mod}") # resolved through the bound package's __path__
if __name__ != "__main__":
sys.modules[__name__] = _impl # `import {mod}` is lib_py.delegation.{mod}
else:
sys.exit(_impl.main())
규칙(문서화·테스트 대상):
- 바인딩 단위는 패키지다.
lib_py.delegation이sys.modules에 없을 때만 shim 옆 트리의lib_py/delegation/을 패키지로 등록하고, 이후 모든lib_py.delegation.*임포트는 그__path__안에서 해석된다. 잎/비-잎 구분 없이 단일 트리가 보장된다. - bind-once, never rebind. 이미 바인딩돼 있으면(다른 트리라도) 그대로 쓴다. "가장 가까운 사본으로 교체"는 기존 참조(
sys.modules['mqtt_common']별칭 등)와 새 객체를 갈라놓아 정체성·monkeypatch를 깨뜨린다(§1-8 B2·D). 네 컨텍스트 중 한 프로세스가 트리를 바꿔야 하는 경우는 없다. - 부모
lib_py는 절대 재바인딩하지 않고,sys.path도 건드리지 않는다. 유일한 예외는lib_py가 어디에도 없는 standalone(C1)이며, 이때만 자기 트리를append한다(가릴 대상이 없으므로 안전). - 결과적으로 pytest에서는 위임 패키지만 "최초 shim의 샌드박스 사본(또는 먼저 직접 임포트된 실제 트리)"에 바인딩되고
lib_py.agents/layout/state등은 항상 실제 트리다. 샌드박스 사본은copytree된 동일 내용이므로 검증 대상 코드는 같다(PV-1이 이를 단정). mqtt_common.py의 PEP 562__getattr__("LOGS_DIR")은 실제 모듈에 남아 별칭을 통해 노출. 실행 비트 유지,--help출력 동일._load_dotenv(D6 이후ensure_env_loaded)의 루트 탐색은 실모듈__file__기준이므로 바인딩된 트리의 루트(저장소 루트 또는 freeze 루트)가 된다 — 이동 전과 동일.
D2-1. 실행 컨텍스트별 "어느 트리가 이겨야 하는가" (Rev.2)
| # | 컨텍스트 | lib_py.delegation 해석 주체 |
이겨야 하는 트리 | Rev.2 결과 | 테스트 |
|---|---|---|---|---|---|
| K1 | 루프 프로세스(run_loop.sh가 freeze lib.sh source → PYTHONPATH=freeze) 및 그 자식(래퍼, -m lib_py.delegation.cli) |
PYTHONPATH |
freeze | freeze | K1: PYTHONPATH=<copy> + -m lib_py.delegation.cli --help |
| K2 | 에이전트 세션(PYTHONPATH=live)이 위임 메시지의 freeze 경로 스크립트를 실행 |
shim 옆 트리 | freeze (B-13) | freeze, lib_py 부모는 live(무해: D1(b)) |
C2_pe |
| K3 | 라이브 헬퍼(create/stop/resume/reconcile; live lib.sh source) |
PYTHONPATH=live 또는 shim 옆 트리(같음) |
live | live | K3: 실제 트리에서 shim 실행 |
| K4 | standalone(PYTHONPATH 없음; BOOTSTRAP.md의 수동 명령, 외부 셸) |
shim 폴백 append | 자기 트리 | 자기 트리 | C1, C1_pe |
| K5 | pytest 인프로세스(conftest가 실제 트리 삽입, 샌드박스 shim 임포트) | 최초 shim 또는 최초 직접 임포트 | 위임: 최초 바인딩(사본=동일 내용) / 나머지 lib_py.*: 실제 트리 |
A, A_pe, B, B2, D, E | PV-1~PV-3 |
| K6 | pytest 서브프로세스(e2e :414, freeze 시뮬레이션) — 새 인터프리터, PYTHONPATH=live 상속 가능 |
shim 옆 트리 | 샌드박스 | 샌드박스 | K6: 서브프로세스 JSON 출력 단정 |
D2-2. 가드 — 정체성만으로는 불충분, 출처(provenance)를 병기 (Rev.2)
- PV-1 (인프로세스, 각 shim 임포트 직후):
mqtt_common is lib_py.delegation.mqtt_common그리고Path(lib_py.delegation.__path__[0]).resolve()가{실제 skills 디렉터리, pytest basetemp 하위}중 하나이며, 4개 별칭(mqtt_common/registry/publish_event/job_subscriber)의__file__이 모두 같은__path__[0]아래. - PV-2 (세션 종료 시, conftest의 session-scope autouse finalizer):
lib_py.__file__, 그리고sys.modules에 있는 모든lib_py.<x>중x != "delegation"의__file__이 실제 skills 디렉터리 아래. 위반 시 어느 모듈이 어느 tmp 경로에서 왔는지 출력. - PV-3 (세션 종료 시 위생):
sys.path항목 중 basetemp 하위인 것 가운데<entry>/lib_py가 디렉터리인 항목 0개. (scripts 디렉터리 삽입은lib_py를 담지 않으므로 허용 — 현행test_tier1_unit.py:23과 호환.) - PV-4 (서브프로세스 매트릭스): §1-8의 시나리오 A, A_pe, B, B2, C1, C1_pe, C2, C2_pe, D, E를 실제 이동된 트리의 사본으로 재실행하고 JSON으로 트리명을 받아 단정. K1·K3·K6도 같은 하니스에 포함.
- PV-5: 4개 shim의 부트스트랩 블록이
{mod}치환을 제외하고 바이트 동일.
D3. 셸에 남는 것과 파이썬으로 가는 것
_delegate_py_bin,_delegate_script: 셸 유지. 시그니처·출력 불변.delegate_submit_job,delegate_publish_event,ensure_subscriber_daemon(신규 이름) +start_watchdog(별칭): 본체를python -m lib_py.delegation.cli ...호출로 교체.PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"를 명시 전달(env_pythonL1583과 동일).-m경로는 shim을 거치지 않으므로 D2-1의 K1/K3처럼SKILL_DIR트리가 결정한다.- 롤백 스위치
MAM_DELEGATION_PY(기본1):0이면 기존 인라인 본체(_*_legacy)로 분기. 특성화 테스트를 두 경로에 대해 parametrize. legacy 본체 제거는 Phase 4.
start_watchdog() { ensure_subscriber_daemon "$@"; } # 기존 함수 별칭 보존
ensure_subscriber_daemon() {
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.cli \
ensure-daemon --job "$1" --workdir "${2:-$PWD}"
}
delegate_publish_event() {
local job_id="$1" event="$2" detail="${3:-}"
[ -n "$job_id" ] || return 0 # 비치명 계약 1
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.cli \
publish --job "$job_id" --event "$event" --detail "$detail" || true # 비치명 계약 2·3
}
D4. cli.py는 라우터, 로직은 기존 main() 재사용
publish→publish_event.main(argv)그대로 호출(exit 0/1/2, stdout 무출력, 옵션 전부 통과).submit→registry.main(["--registry-dir", ..., "register", ...]). stdout에 JID 한 줄, 로그는 stderr.ensure-daemon→daemon.ensure_subscriber_daemon(job_id, workdir); stdout PID, exit 0 / 부재 시 stderr 동일 문구 + exit 1.- 래퍼
multi-agent-mux-delegate-job(bash)은 Phase 1에서 수정하지 않는다(G-10 보호).
D5. daemon.ensure_subscriber_daemon(job_id, workdir) -> int 의미 보존
| 항목 | 레거시(lib.sh:1912-1941) |
파이썬 이식 |
|---|---|---|
| 모니터 스크립트 경로 | <workdir>/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh |
동일 |
| 부재 시 | stderr ERROR: monitor script not found: <path>, rc 1 |
동일 문구·rc |
| 중복 검사 | pgrep -f "bash <path> --subscribe" |
subprocess.run(["pgrep","-f",...]) 동일 패턴 |
| 기존 PID 출력 | echo "$pid" (다중 매치면 여러 줄) |
동일하게 전량 출력(정리는 Phase 4 후보) |
| 스폰 | nohup bash <path> --subscribe --idle-timeout 0 </dev/null >> <workdir>/.mam/multi-agent-mux-monitor.log 2>&1 &, cwd=workdir |
Popen([...], cwd=workdir, stdin=DEVNULL, stdout=log(append), stderr=STDOUT, start_new_session=True, preexec_fn=SIGHUP 무시) |
| 반환 | 스폰된 PID | 동일 |
| 데몬 자체 중복 방지 | reconcile.sh:86-95 .mam/monitor.lock flock |
변경 없음 |
D6. 임포트 시점 부작용 제거 — _load_dotenv()를 진입점 시점으로
mqtt_common.py:112의 모듈 레벨 _load_dotenv() 호출을 ensure_env_loaded()(멱등, 1회)로 바꾸고 모든 프로세스 진입점 첫 줄에서 호출: publish_event.main, job_subscriber.main, registry.main, cli.main, bench.main, reconcile.sh 임베디드 구독자(import mqtt_common 직후 1줄). 이의제기 §6이 독립 검증한 대로 mqtt_common.py의 env 참조는 전부 함수 내부이고 모듈 상수는 env 무관하므로 값이 달라지는 상수는 없다. conftest.mam_sandbox에 monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent")) 추가(_load_dotenv:48-52는 명시 파일 부재 시 무로딩).
D7. 발행 경로 분리 — 전송(transport)만 교체 가능하게
publish_event.main()(:133-235)을 동작 불변으로 세 조각으로 나눈다.
def prepare_event(job_id, event, detail, data, registry_dir) -> PreparedEvent # load_job, broker cfg, topic, next_seq(소비), build_payload, retain 결정
def record_outcome(prepared, registry_dir, publish_ok, publish_error) -> None # append_event(audit) + registry.append_event + EVENT_TO_STATUS 동기화
def main(argv=None) -> int: # parse → ensure_env_loaded → prepare_event → with_retry(_publish_once) → record_outcome → 0/2
- G-1/G-2/G-4의 의미가
record_outcome한 곳에 남는다. publisher.OneShotPublisher.publish(prepared)= 기존_publish_once.publisher.PersistentPublisher: 컨텍스트 매니저. 1회 연결 +loop_start()+reconnect_delay_set(1,16);publish(prepared)는 QoS1 발행 +wait_for_publish(PUBLISH_ACK_TIMEOUT); 미확인 시 예외를 던져 호출자의with_retry가 재시도. 브로커 설정이 다른 job 혼입 시 예외.service.DelegationService(registry_dir, persistent=False):submit_job(),publish_event(),ensure_subscriber_daemon(). CLI/셸은 항상persistent=False. Phase 2a 엔진이persistent=True의 첫 소비자.- 패리티 테스트: 동일 입력·가짜 전송에서 OneShot과 Persistent의 레지스트리(
last_seq,status)·감사 레코드(published,seq,retain,payload.data.hmac_sig)가 필드 단위로 동일.
D8. 벤치마크 — 결정적 지표와 정보성 지표 분리
python -m lib_py.delegation.bench --n 50 --transport {fake|broker} [--connect-latency-ms 50] --out <json>
| 지표 | fake 전송(CI에서 단정) | broker 전송(수동, 정보성) |
|---|---|---|
| 연결 수립 횟수 / N회 발행 | OneShot = N, Persistent = 1 (+재연결 수) — 테스트로 고정 | 동일 카운트 실측 |
| 발행당 지연 p50/p95/max | 합성 지연 기준 산술 확인 | 실측 (CONNACK 타임아웃 건수 별도) |
| 프로세스 포크 수(셸 경로) | delegate_publish_event 1회당 레거시 3 fork → 신규 2 fork |
분석값 |
결과는 docs/contracts/issue-5/phase1-benchmark.md에 환경과 함께 기록. 실브로커 수치는 게이트 조건이 아니다.
D9. 비범위 및 호환성 계약(재확인)
- 저장소 통합 금지:
lib_py/delegation은lib_py.state/lib_py.atomic_yaml을 임포트하지 않는다 — D1(b)가 이를 포함하는 더 넓은 규칙. - 스키마 불변:
SCHEMA_VERSION = 1, 레코드 필드, 토픽, 감사 로그 레이아웃,<id>.events.log형식 전부 불변 → §3 라이브 헬퍼 버전 게이트상 ABI 파괴 없음. K2에서 부모lib_py(live)와 위임 패키지(freeze)가 다른 트리인 것은 D1(b) 덕분에 관측 가능한 차이를 만들지 않는다. job_subscriber는 이미 실행당 장수 연결이므로 전송 변경 없음.- 자동완료 관련 코드 일체 추가 금지.
3. 작업 분해 (WBS) — 순서 고정, 커밋 5개
P1-00 특성화 테스트 선행 (커밋 1: test(issue-5): phase 1 delegation characterization suite)
- 파일:
tests/test_issue5_phase1_delegation.py(신규),tests/conftest.py(MAM_ENV_FILE1줄 + PV-2/PV-3 session finalizer),tests/fixtures/issue_5/phase1/(가짜reconcile.sh, 가짜python스텁,shim_contexts.py하니스 — 부록 B). - 셸 헬퍼 golden 테스트 (샌드박스에서
source lib.sh후 실행,_delegate_py_bin은test_c1_tui_readiness.py:513방식으로 스텁):- C-01
delegate_publish_event "" started→ rc 0, python 미실행. - C-02 스크립트 부재 → rc 0.
- C-03 python 스텁 exit 2 → rc 0.
- C-04 정상 경로 argv에
--job <id> --event <ev> --detail <detail>정확히 포함. - C-05
delegate_submit_job p a s→ stdout이 스텁의 JID 한 줄과 정확히 동일. - C-06
start_watchdog스크립트 부재 → stderr 정확 문구, rc 1. - C-07 신규 스폰 → stdout 숫자 PID, 생존, cwd=workdir, 로그 파일 생성.
- C-08 두 번째 호출 → 동일 PID, 프로세스 수 불변.
- C-09
start_watchdog≡ensure_subscriber_daemon— P1-02 이후 활성. MAM_DELEGATION_PY∈ {0,1} parametrize.
- C-01
- CLI 계약 스냅샷: argparse 파서에서 옵션명·choices·default를 추출해 단정.
- 자동완료 불변 가드:
EVENT_TO_STATUS동등; 래퍼:335-348리터럴;reconcile.sh_MUTATION핵심 4줄 리터럴. - PV-2/PV-3 finalizer는 이 커밋에서 이미 활성 — 현행 트리에서 통과해야 한다(shim 도입 전 기준선).
- 완료 기준: 신규 테스트 전부 통과(레거시 코드 그대로), 전체 스위트 510 + 신규 통과.
P1-01 패키지 생성·이동·shim (커밋 2: refactor(issue-5): move delegation scripts into lib_py.delegation with package-bound shims)
- 파일:
git mv4종, 내부 임포트 교정,lib_py/delegation/__init__.py,scripts/*.py4종 shim(D2 블록),mqtt_common.ensure_env_loaded()(D6) + 4개main()및reconcile.sh임베디드 구독자 1줄. - 추가 테스트(Rev.2 확장): PV-1(4 별칭), PV-4 서브프로세스 매트릭스 10 시나리오 + K1/K3/K6, PV-5 shim 동일성, "라이브러리 임포트 후
os.environ키 집합 불변",ensure_env_loaded멱등, D1(a)(b) 임포트 규칙 grep,reconcile.sh --subscribe --idle-timeout 1이test_comp_monitor_concurrency_lock과 동일 동작. - 완료 기준: 전체 스위트 통과,
git diff --check,compileall, 3개 shim--help출력이 이동 전 캡처본과 diff 없음, PV-2/PV-3가 전체 스위트 실행 후에도 통과(이것이 이의제기가 요구한 "어느 트리를 검증했는지" 증빙).
P1-02 서비스·CLI·데몬 + 셸 헬퍼 교체 (커밋 3: feat(issue-5): DelegationService, cli router, ensure_subscriber_daemon; lib.sh helpers delegate to python)
- 파일:
service.py,daemon.py,cli.py,__main__.py;lib.shL1876-1941 교체(D3). - 추가 테스트: daemon 단위(PID 생존·중복·로그 경로·부모 종료 후 생존),
cli publish≡publish_event.main(G-1G-4 cli 경로 parametrize),C-09 신규 본체 재통과.cli submitstdout, C-01 - 완료 기준: 두 경로 특성화 통과,
bash -n6개 파일, 전체 스위트 통과.
P1-03 장수 연결 프로토타입 (커밋 4: feat(issue-5): persistent MQTT publisher prototype and benchmark harness)
- 파일:
publish_event.py함수 분리(D7),publisher.py,service.pypersistent옵션,bench.py. - 추가 테스트: 가짜 paho 클라이언트로 (a) OneShot N회 = N connect, Persistent = 1, (b) 패리티, (c) PUBACK 미확인 → 재시도 →
record_outcome(published=False), (d) 브로커 설정 상이 혼입 예외, (e)bench --transport fake스키마·카운트. 네트워크 접촉 0. - 완료 기준: 위 테스트 통과, G-1~G-4 동일 통과, 전체 스위트 통과.
P1-04 벤치마크 실측 및 문서 (커밋 5: docs(issue-5): phase 1 benchmark results, contract mapping, task checklist)
- 실행: fake 모드 + broker 모드 1회(임시 레지스트리).
docs/contracts/issue-5/phase1-benchmark.md에 기록. - 문서 갱신:
SKILL.md(version4.2.0→4.3.0, "구현은lib_py.delegation,scripts/*는 패키지 바인딩 shim" 1단락 + D2-1 표 요약), 스킬README.md,registry.md/job-protocol.md각주,deploy/install.shcore_files에.agents/skills/lib_py/delegation/__init__.py추가(무결성 목록 갱신 목적; 배포 자체는 트리 통째 복사라 누락 위험 없음),baseline.md§6 매핑 행,task.mdP1-01~03 체크 + 증거,implementation_plan.mdPhase 1 행 상태. - 라이브 스모크(수동, 보고서 기록): 임시 레지스트리에서
registry.py register→ shimpublish_event.py --event started→-m lib_py.delegation.cli publish --event completed→<id>.events.log2줄,events.ndjsonpublished2건,status.json=completed. 이어MAM_DELEGATION_PY=0으로 반복. 추가(Rev.2): 에이전트 세션과 같은 조건(PYTHONPATH=live)에서 freeze 사본 경로의 shim을 실행하고python -c로lib_py.delegation.__path__가 freeze 사본임을 출력해 첨부(K2 실증).
4. 계약 보존 매트릭스
| 계약 | 구현 위치(이동 후) | 보장 테스트 |
|---|---|---|
스크립트 리터럴 경로·실행 비트·--help |
scripts/*.py shim |
P1-01 --help diff, K6, e2e :414 |
import mqtt_common 등 최상위 이름 → 단일 트리의 동일 객체 |
shim 패키지 바인딩 + 별칭 | PV-1(정체성+출처), PV-4 |
위임 외 lib_py.*는 항상 실제 트리(pytest) / 부모 무재바인딩 |
shim 규칙 3 | PV-2, PV-3 |
publish_event exit 0/1/2, stdout 무출력 |
publish_event.main 불변 |
G-1~G-4 + cli parametrize |
| seq 단조·실패 시 소비 | prepare_event→next_seq |
G-4, test_comp_monitor_monotonic_seq_updates |
HMAC data.hmac_sig |
build_payload 단일 경로 |
test_mqtt_verify_hmac_*, 패리티 |
| 터미널 이벤트 retain | prepare_event |
패리티(retain) |
job_subscriber exit 0/1/2/3, 디스크 폴백 3s |
이동만 | G-5~G-9 |
| 셸 헬퍼 비치명 | 셸 가드 + || true |
C-01~C-04 (두 경로) |
delegate_submit_job stdout=JID 1줄 |
cli submit + stderr 로깅 |
C-05 |
| 데몬 PID/수명/중복/로그 경로 | daemon.py |
C-06~C-09, daemon 단위 |
start_watchdog 이름 |
별칭 | C-09 |
감사 로그 레이아웃·DELEGATE_JOB_LOGS_DIR |
mqtt_common 불변 |
G-2, G-3 |
| env 로딩 순서(OS env 우선), 라이브러리 임포트 무부작용 | ensure_env_loaded(진입점) |
env 테스트 2종 |
| 자동완료 3표면 불변 | 변경 없음 | 불변 가드 3종 |
| 래퍼 텍스트 불변 | 변경 없음 | G-10 + 확장 |
위임 패키지의 형제 lib_py 모듈 무임포트 |
D1(b) | grep 테스트 |
| freeze(B-13) — K1/K2 모두 freeze 트리 | PYTHONPATH(K1) / shim 바인딩(K2) |
K1, C2, C2_pe, P1-04 실증 |
5. 게이트
| # | 조건 | 측정 방법 |
|---|---|---|
| G1 | 기존 510개 + 신규(예상 40±5) 전부 통과, skipped 0 | .venv/bin/python -m pytest tests/ -q (≈15분, 백그라운드) |
| G2 | 셸 문법·공백 클린 | bash -n 6개 파일, git diff --check |
| G3 | Python 3.9 컴파일 | compileall -q .agents/skills/lib_py/delegation |
| G4 | 정체성+출처(PV-1), 위임 외 lib_py 실제 트리·sys.path 위생(PV-2/PV-3, 전체 스위트 종료 시점), 컨텍스트 매트릭스(PV-4 10+3), shim 동일성(PV-5), env 무오염 |
P1-01 테스트 + conftest finalizer |
| G5 | 데몬/PID 계약 | C-06~C-09 두 경로 |
| G6 | 자동완료 3표면 불변 | 불변 가드 + git diff --stat(래퍼 무변경, reconcile.sh는 env 1줄만) |
| G7 | 벤치 결정적 지표 + 실측 문서 | fake 전송 테스트, phase1-benchmark.md |
| G8 | 롤백 드릴 | MAM_DELEGATION_PY=0 + 라이브 스모크 |
| G9 | 라이브 스모크(shim·cli·K2 freeze 사본) 감사 로그 정합 | §3 P1-04 |
Worker 완료 보고서에는 G1 pytest 요약 원문, G4의 PV-2/PV-3 finalizer 통과 로그, G7 표, G9 발췌(K2 __path__ 출력 포함)를 그대로 붙인다. file:line 인용은 grep -n 출력으로 뒷받침.
6. 롤백 절차
- 즉시:
export MAM_DELEGATION_PY=0→ 셸 헬퍼 legacy 분기. 패키지·shim은 그대로 두어도 무해. - 커밋 단위: 5→4→3 순
git revert; 커밋 2까지 되돌리면 이동 전 상태. 커밋 1은 레거시에서도 통과. - 진행 중 루프: freeze 스냅샷은 시작 시점 트리이므로 롤백은 다음 루프부터. 라이브 헬퍼는 즉시 legacy. 스키마 불변이므로 혼재 호환.
7. 리스크와 완화
| # | 리스크 | 완화 |
|---|---|---|
| R1 | 모듈 사본 분기(테스트 shim vs 프로덕션 패키지) | D2 패키지 바인딩 + PV-1 출처 단정 |
| R2 | 임포트 시 .mam.env 주입 오염 |
D6 + conftest MAM_ENV_FILE |
| R3 (Rev.2 재작성) | shim 부트스트랩이 pytest에서 위임 외 lib_py를 tmp 사본으로 가로채거나(insert), freeze에서 live에 밀리거나(append), 비-잎 모듈에서 두 트리가 섞임(잎 파일 적재) |
해소: 패키지 단위 bind-once, sys.path 무변경, 부모 무재바인딩. PV-2/PV-3/PV-4가 회귀를 검출 |
| R4 | Python 3.10+ 문법 | G3 |
| R5 | 공용 브로커 불안정 | 테스트는 비라우팅 포트/가짜 전송만 |
| R6 | pgrep -f 패턴 |
레거시 그대로, C-08 |
| R7 | $( ) 캡처 stdout 오염 |
stderr 로깅, C-05/C-07 |
| R8 | 래퍼 리터럴(G-10) 파손 | 래퍼 무수정 |
| R9 (Rev.2 수정) | lib.sh 미소싱/PYTHONPATH 부재 환경에서 shim·-m 실패 |
shim: lib_py 부재 시에만 자기 트리 append(K4). -m 경로: 셸 헬퍼는 항상 lib.sh 내부이므로 SKILL_DIR 존재 + 명시 전달 |
| R10 | lib_py.agents.registry 혼동 |
D1(a) grep |
| R11 | start_new_session vs nohup |
SIGHUP 무시, 생존 테스트 |
| R12 (신규) | K2에서 부모 lib_py(live)와 위임 패키지(freeze)가 다른 트리 |
D1(b): 위임 패키지는 형제 lib_py 모듈을 임포트하지 않음(grep 고정). 현재 0건 |
| R13 (신규) | 두 트리의 위임 모듈을 한 프로세스에서 동시에 써야 하는 경우 | 존재하지 않음(K1~K6 전수). 발생 시 bind-once 규칙상 최초 트리 유지 — 문서화 |
| R14 (신규) | pytest에서 위임 패키지가 샌드박스 사본에 바인딩되어 "실제 트리 미검증" 우려 | 사본은 copytree 동일 내용이며 PV-1이 사본 위치를 basetemp로 한정. K6 서브프로세스가 사본 경로 실행을 별도 검증 |
8. 명시적 비범위
lib.shL1943 이후 TUI 헬퍼의 파이썬화.- 래퍼,
run_loop.sh,reconcile.sh(env 1줄 제외) 수정. job_subscriber/모니터 전송 변경, 스키마 v2, 토픽 변경.- 자동완료 로직 일체(Phase 2a/2b).
- 저장소 통합, Windows,
pgrep다중 PID 정리(Phase 4 후보). - 기존 tier1/tier2 테스트의 임포트 방식 변경(회귀 오라클 보존).
9. Worker 인수인계 체크리스트
- P1-00 커밋 1: 특성화·스냅샷·불변 가드 + PV-2/PV-3 finalizer가 레거시 코드에서 통과
- P1-01 커밋 2:
git mv4종, D2 shim 4종(PV-5 동일),ensure_env_loaded, PV-1/PV-4 매트릭스, 전체 스위트 녹색 + finalizer 녹색 - P1-02 커밋 3:
service/daemon/cli,lib.sh교체(legacy 보존,MAM_DELEGATION_PY), 두 경로 특성화 녹색,bash -n - P1-03 커밋 4:
prepare_event/record_outcome,PersistentPublisher, 패리티·카운트, 네트워크 0 - P1-04 커밋 5: 벤치 문서, SKILL/README/registry.md/job-protocol.md/install.sh/baseline §6/task.md/implementation_plan.md, K2 실증 첨부
- 완료 보고서: G1 원문, G4 finalizer 로그, G7 표, G9 발췌,
grep -n인용 - 커밋 접두:
test(issue-5),refactor(issue-5),feat(issue-5),feat(issue-5),docs(issue-5)
부록 A. 계획 수립 시 실행한 검증 명령 (Rev.1 + Rev.2)
awk 'NR>=1800 && /^[a-zA-Z_][a-zA-Z0-9_]*\(\)/ {print NR": "$0}' .agents/skills/lib.sh
grep -rn 'delegate_publish_event\|delegate_submit_job\|start_watchdog\|_delegate_py_bin' --include='*.sh' --include='*.py' .agents tests
grep -l 'delegate_publish_event\|start_watchdog\|delegate_submit_job' tests/*.py # → (없음)
grep -n 'lib_py' .agents/skills/multi-agent-mux-delegate-job/scripts/*.py # → (없음) D1(b) 전제
echo "$PYTHONPATH" # 에이전트 세션 = 라이브 트리
grep -n PYTHONPATH .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh # 0건
sed -n '24,25p;163,164p' .agents/skills/lib.sh # SKILL_DIR 기준 PYTHONPATH export
ls /var/folders/.../mam-loop-freeze.PfAQNj/.agents/skills/lib_py # freeze에 lib_py 존재
grep -n 'os.environ' .agents/skills/multi-agent-mux-delegate-job/scripts/mqtt_common.py # 전부 함수 내부
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 510
부록 B. shim 컨텍스트 재현 하니스 (P1-01 PV-4의 원형)
세 변형 × 시나리오를 새 인터프리터(env -u PYTHONPATH)에서 실행하고 각 모듈의 TREE 마커를 출력한다. Worker는 이 하니스를 tests/fixtures/issue_5/phase1/shim_contexts.py로 옮기고, 가짜 트리 대신 실제 이동된 트리의 사본(copytree)을 real/sandbox/sandbox2/freeze로 사용하며, TREE 마커 대신 __file__/__path__ 접두어로 트리를 판별하도록 바꾼다.
# 트리 구성: <tree>/lib_py/{__init__.py, agents/__init__.py(TREE), delegation/{__init__.py, mqtt_common.py(TREE), publish_event.py(TREE + from lib_py.delegation import mqtt_common)}}
# <tree>/multi-agent-mux-delegate-job/scripts/{mqtt_common,publish_event}.py ← D2 shim
# 시나리오 (sys.path 조작은 conftest/tier1/실행 방식을 모델링):
# A : insert(0, real); insert(0, sandbox/scripts); import mqtt_common, publish_event
# A_pe : 같음, import publish_event 만
# B : insert(0, real); import lib_py.agents; insert(0, sandbox/scripts); import mqtt_common, publish_event
# B2 : insert(0, real); import lib_py.delegation.mqtt_common; insert(0, sandbox/scripts); import mqtt_common, publish_event
# C1 : insert(0, freeze/scripts); import mqtt_common, publish_event (PYTHONPATH 없음)
# C1_pe: 같음, publish_event 만
# C2 : insert(0, real); insert(0, freeze/scripts); import mqtt_common, publish_event
# C2_pe: 같음, publish_event 만 ← 에이전트 호출 형태
# D : insert(0, real); insert(0, sandbox/scripts); import mqtt_common; insert(0, sandbox2/scripts); import publish_event
# E : insert(0, real); insert(0, sandbox/scripts); import mqtt_common; from lib_py.delegation import publish_event
# 단정: 각 시나리오에서 {alias_mc, pkg_mc, pe, pe.dep} 가 단일 트리이고, pytest 계열(A,A_pe,B,B2,D,E)에서 agents == real,
# identity(alias is pkg) == True, sys.path 에 lib_py 를 담은 비-real 루트가 없음(C1 계열 제외).