Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/plan-923e9403.md
T

464 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📑 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 (Job `c0b9c987`) — D2 shim 부트스트랩의 컨텍스트 충돌
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5) — Phase 1 (`implementation_plan.md` Rev.4 §2 Phase 1 행, `task.md` P1-01~P1-03)
- **기준 커밋**: `fc0bd96` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / 전체 테스트 수집 **510**
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 구현 명세. 모든 인용 행 번호는 라이브 코드로 직접 확인함(§1). Rev.1 대비 변경은 §0-1에 모아 두었고, 본문에는 이미 반영되어 있다.
---
## 0. 요약 (TL;DR)
1. **추출 대상은 `lib.sh` L1833–1941의 위임 블록 5개 함수뿐이다.** L1943 이후(`wait_for_tui_ready`, `send_keys_safe` 등)는 TUI 조작 헬퍼이며 Phase 1 비범위다.
2. **`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**으로 남긴다.
3. **shim은 `sys.path`를 건드리지 않고 `lib_py.delegation` 패키지만 자기 옆 트리에 "프로세스당 1회" 바인딩한다(Rev.2, D2).** 이의제기가 실증한 대로 `sys.path.insert(0, ...)` 방식은 pytest에서 위임과 무관한 `lib_py.*`까지 임시 사본으로 가로챈다. 이의제기가 제안한 "잎(leaf) 모듈 파일 직접 적재"도 재현 결과 비-잎 모듈(`publish_event`)에서 두 트리가 섞이므로 채택하지 않고, **패키지 단위 bind-once**로 확정했다(§1-8 근거).
4. **현재 셸 헬퍼 3종을 직접 검증하는 테스트는 0개다.** 이동 전에 특성화(golden) 테스트를 먼저 추가한다(P1-00).
5. **장수 연결 프로토타입은 라이브러리 전용이다.** CLI는 프로세스 단위로 종료되므로 재사용 이득이 없다. 벤치마크는 결정적 가짜 전송 + 실브로커(정보성) 2모드.
6. **자동완료 정책은 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` 처리가 없고(`grep` 0건) `:14-17`에서 자기 위치 기준 `REPO_ROOT`의 `lib.sh`를 source 하므로 **루프 프로세스 자체는 freeze `lib.sh`가 export한 freeze `PYTHONPATH`** 를 갖는다.
### 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 불변)
1. `publish_event.py:51-56` `EVENT_TO_STATUS` 및 `:227`의 `update_job_status` 동기화.
2. 래퍼 `:335-348` 구독자 exit code → `job_status` 매핑 및 `:394/:400`의 `registry.py status --set completed|error`.
3. `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-2 `VALID_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이 남의 헬퍼를 쓰게 되기 때문이다. 동일성은 테스트로 고정한다.
```python
#!/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())
```
규칙(문서화·테스트 대상):
1. **바인딩 단위는 패키지**다. `lib_py.delegation`이 `sys.modules`에 없을 때만 shim 옆 트리의 `lib_py/delegation/`을 패키지로 등록하고, 이후 모든 `lib_py.delegation.*` 임포트는 그 `__path__` 안에서 해석된다. 잎/비-잎 구분 없이 단일 트리가 보장된다.
2. **bind-once, never rebind.** 이미 바인딩돼 있으면(다른 트리라도) 그대로 쓴다. "가장 가까운 사본으로 교체"는 기존 참조(`sys.modules['mqtt_common']` 별칭 등)와 새 객체를 갈라놓아 정체성·monkeypatch를 깨뜨린다(§1-8 B2·D). 네 컨텍스트 중 한 프로세스가 트리를 바꿔야 하는 경우는 없다.
3. **부모 `lib_py`는 절대 재바인딩하지 않고, `sys.path`도 건드리지 않는다.** 유일한 예외는 `lib_py`가 어디에도 없는 standalone(C1)이며, 이때만 자기 트리를 `append` 한다(가릴 대상이 없으므로 안전).
4. 결과적으로 pytest에서는 위임 패키지만 "최초 shim의 샌드박스 사본(또는 먼저 직접 임포트된 실제 트리)"에 바인딩되고 `lib_py.agents`/`layout`/`state` 등은 항상 실제 트리다. 샌드박스 사본은 `copytree`된 동일 내용이므로 검증 대상 코드는 같다(PV-1이 이를 단정).
5. `mqtt_common.py`의 PEP 562 `__getattr__("LOGS_DIR")`은 실제 모듈에 남아 별칭을 통해 노출. 실행 비트 유지, `--help` 출력 동일.
6. `_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_python` L1583과 동일). `-m` 경로는 shim을 거치지 않으므로 D2-1의 K1/K3처럼 `SKILL_DIR` 트리가 결정한다.
- 롤백 스위치 `MAM_DELEGATION_PY`(기본 `1`): `0`이면 기존 인라인 본체(`_*_legacy`)로 분기. 특성화 테스트를 두 경로에 대해 parametrize. legacy 본체 제거는 Phase 4.
```bash
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`)을 동작 불변으로 세 조각으로 나눈다.
```python
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_FILE` 1줄 + 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.
- **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 mv` 4종, 내부 임포트 교정, `lib_py/delegation/__init__.py`, `scripts/*.py` 4종 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.sh` L1876-1941 교체(D3).
- **추가 테스트**: daemon 단위(PID 생존·중복·로그 경로·부모 종료 후 생존), `cli publish` ≡ `publish_event.main`(G-1~G-4 cli 경로 parametrize), `cli submit` stdout, C-01~C-09 신규 본체 재통과.
- **완료 기준**: 두 경로 특성화 통과, `bash -n` 6개 파일, 전체 스위트 통과.
### P1-03 장수 연결 프로토타입 (커밋 4: `feat(issue-5): persistent MQTT publisher prototype and benchmark harness`)
- **파일**: `publish_event.py` 함수 분리(D7), `publisher.py`, `service.py` `persistent` 옵션, `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`(version `4.2.0`→`4.3.0`, "구현은 `lib_py.delegation`, `scripts/*`는 패키지 바인딩 shim" 1단락 + D2-1 표 요약), 스킬 `README.md`, `registry.md`/`job-protocol.md` 각주, `deploy/install.sh` `core_files`에 `.agents/skills/lib_py/delegation/__init__.py` 추가(무결성 목록 갱신 목적; 배포 자체는 트리 통째 복사라 누락 위험 없음), `baseline.md` §6 매핑 행, `task.md` P1-01~03 체크 + 증거, `implementation_plan.md` Phase 1 행 상태.
- 라이브 스모크(수동, 보고서 기록): 임시 레지스트리에서 `registry.py register` → shim `publish_event.py --event started` → `-m lib_py.delegation.cli publish --event completed` → `<id>.events.log` 2줄, `events.ndjson` `published` 2건, `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. 롤백 절차
1. **즉시**: `export MAM_DELEGATION_PY=0` → 셸 헬퍼 legacy 분기. 패키지·shim은 그대로 두어도 무해.
2. **커밋 단위**: 5→4→3 순 `git revert`; 커밋 2까지 되돌리면 이동 전 상태. 커밋 1은 레거시에서도 통과.
3. **진행 중 루프**: 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.sh` L1943 이후 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 mv` 4종, 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)
```bash
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__` 접두어로 트리를 판별하도록 바꾼다.
```python
# 트리 구성: <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 계열 제외).
```