> **본 리포트는 Job `2770df36`(role: Planner)의 산출물인 Phase 2a 구현 계획서다.** > durable 사본: `.agents/reports/creator-claude-01/plan-2770df36.md` (커밋됨). > > **범위 명시**: 본 job의 지시는 "구현 계획서를 작성"이며, 목표 문장의 (5) 테스트 작성·(6) 리뷰어 만장일치 PASS는 이 계획을 실행할 **후속 Worker/Reviewer job**의 완료 조건이다. 본 계획서는 그 job들이 그대로 실행할 수 있도록 커밋 5개·게이트 9개·인수인계 체크리스트까지 명세했다. --- # 📑 Issue #5 Phase 2a 구현 계획서 — `lib_py/loop/` 코어 & Shadow Watcher - **Job**: `2770df36` / **Planner**: `creator-claude-01` - **기준 커밋**: `015ab70` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / **전체 테스트 수집 576** - **요구 출처**: `implementation_plan.md` §2 Phase 2a 행, `task.md` P2A-01~P2A-04, `state-transitions.md` §2~§5 - **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 계획 수립 중 **라이브 코드·라이브 CLI로 직접 확인**했다. --- ## 0. 요약 (TL;DR) 1. **Phase 2a는 레지스트리에 단 1바이트도 쓰지 않는다.** Shadow watcher는 "Would-Complete" 판정을 로그로만 남긴다. 이 불변식을 문장이 아니라 **구조와 테스트로** 강제한다(§D5, G4). 2. **CAS 경계는 신규 파일이 아니라 기존 writer 3곳을 한 함수 뒤로 모으는 작업이다.** 현재 상태 writer는 3개이고 그중 **하나(`pick_pending`)는 이미 문서화된 chokepoint를 우회**한다(§1-2 실측). 3. **4중 귀속에 필요한 필드가 job 레코드에 없다**(`attempt_id`/`agent_session_uuid`/`pane_process_generation` 전부 부재, §1-4). Phase 1이 레코드 스키마 불변을 약속했고 Phase 2a는 레지스트리 쓰기가 금지되므로, **귀속 증거는 shadow 전용 저장소에 둔다.** 레코드 스키마 결정은 Phase 2b로 미룬다. 4. **`herdr agent wait --timeout`의 단위는 밀리초다**(라이브 `--help` 확인, Phase 0 fixture도 `integer_ms`로 정확히 기록). 초 단위로 오해하면 1000배 오차가 난다. 5. **락 안에서 감사 로그 I/O가 일어나고 있다**(`update_job_status`가 락 보유 중 `update_logged_status`/`append_event` 호출, §1-3). Phase 2a는 이를 "락 안에서 결정 + 알림 큐잉 → 락 해제 → 밖에서 flush"로 바꾼다. 6. **판정 로직의 테스트 벡터는 새로 만들지 않는다.** Phase 0에서 3인 리뷰로 검증된 watcher 시나리오 7종과 그 `expected_policy`가 그대로 엔진의 결정 테이블 테스트가 된다(§D7). --- ## 1. Planner 사전 실측 ### 1-1. 대상 파일과 규모 | 항목 | 실측 | |---|---| | 기준 커밋 | `015ab70` | | 전체 테스트 | **576 수집** (Phase 1 종료 시점) | | `lib_py/` 기존 패키지 | `agents/`, `delegation/`, `atomic_yaml.py`, `layout.py`, `paths.py`, `state.py`, `verify_session.py`, `workspace_uuid.py` | | `MAM_ENGINE_CAS` | **아직 존재하지 않음** (`grep` 결과 `implementation_plan.md` 언급 1건뿐) — Phase 2a가 신설 | ### 1-2. job 레코드 상태 writer 전수 조사 (P2A-01의 실제 범위) `_atomic_write_record` 직접 호출자 = **5곳**, 그중 상태를 바꾸는 것은 **3곳**이다. | # | 위치 | 하는 일 | CAS 여부 | |---|---|---|---| | W1 | `mqtt_common.py:410` (`update_job_status`) | `record.update(fields)` **무조건 병합** | ❌ 없음 | | W2 | `registry.py:131` (`pick_pending`) | 락 안에서 `status == "pending"` 필터 후 `running`으로 기록 | ⚠️ **사실상 CAS이나 W1을 우회한 별도 구현** | | W3 | `mqtt_common.py:433` (`next_seq`) | `last_seq` 증가 (상태 무관) | 해당 없음 | | — | `registry.py:107` (`register_job`) | 레코드 생성 | 해당 없음 | | — | `registry.py:151` (`update_status`) → W1 | | | | — | `registry.py:365` (`update` CLI) → W1 | | | | — | `publish_event.py:179` (`record_outcome`) → W1 | | | > **핵심 (정확히)**: `update_job_status`의 docstring은 자신을 "the single chokepoint for status writes"라고 선언하지만, `registry.py:135`의 주석이 스스로 인정하듯 `pick_pending`은 이를 우회한다. > > 단, **`pick_pending`이 틀린 것은 아니다.** 락 안에서 `status == "pending"`을 필터한 뒤 기록하므로 `pending → running`에 대해서는 **이미 올바른 compare-and-swap**이다(`registry.py:123`). 문제는 정확성이 아니라 **중복**이다: CAS 판정과 감사 로그 mirror가 두 벌로 존재하며, Phase 1의 O-1(1회성 전송 이중 구현)에서 확인했듯 **같은 로직의 사본은 한쪽에만 결함이 생기는 경로**다. > > 따라서 **P2A-01은 "없는 CAS를 새로 만드는" 작업이 아니라 "한쪽에만 있는 CAS를 모든 전이로 일반화하고 두 사본을 하나로 합치는" 작업이다.** 실제로 CAS가 전혀 없는 것은 W1(`record.update(fields)` 무조건 병합)이다. 셸/래퍼 측은 job 레코드를 직접 쓰지 않는다: 래퍼는 `registry.py status --set`(→W1)을, `reconcile.sh:192`는 `registry.load_job`(읽기 전용)만 쓴다. `run_loop.sh:254`는 job JSON을 **읽기만** 한다(3초 폴링). 즉 **모든 쓰기가 파이썬 안에 있다** — CAS 경계를 파이썬 한 곳에 두면 전수 커버가 가능하다. ### 1-3. 현재 락 트랜잭션의 I/O 실측 `update_job_status`(`:405-418`)는 `registry_lock` **보유 중에** 다음을 수행한다: - `load_job` (읽기 I/O) - `_atomic_write_record` (쓰기 + `fsync` + `os.replace`) - `update_logged_status` (**감사 로그 디렉터리 쓰기**) - `append_event` (**감사 로그 append**) 네트워크/서브프로세스는 락 안에 없다(`publish_event.main`의 순서: `next_seq`(락) → 발행(네트워크, 락 없음) → `record_outcome`(락)). **이 성질은 Phase 2a에서 반드시 보존해야 하며**, 추가로 감사 로그 I/O를 락 밖으로 빼는 것이 P2A-01의 "락 내 I/O 배제" 요구다. ### 1-4. 4중 귀속에 필요한 필드 부재 (설계를 좌우하는 사실) 라이브 job 레코드의 실제 키: ``` agent, agent_session, auth_token, broker, created_at, expected_artifacts, idle_timeout_sec, iteration, job_id, job_type, last_seq, max_iterations, prompt, reviewer, reviewer_session, role, schema_version, status, timeout_sec, topic_prefix, updated_at ``` `state-transitions.md` §2가 요구하는 4중 귀속 중 **`attempt_id`·`agent_session_uuid`·`pane_process_generation`이 전부 없다**. `iteration`(루프 회차)은 있으나 attempt와 의미가 다르다. 동시에 Phase 1은 "스키마 불변(`SCHEMA_VERSION = 1`, 레지스트리 레코드 필드 불변)"을 계약으로 못박았고, P2A-03은 **레지스트리 쓰기 자체를 금지**한다. → **Phase 2a에서 귀속 증거를 레코드에 넣는 것은 불가능하며, 넣어서도 안 된다.** §D6이 이를 해결한다. ### 1-5. `herdr agent wait` 라이브 계약 (watcher.py의 입력) ``` Usage: herdr agent wait [OPTIONS] --until idle|working|blocked|done|unknown (반복 가능) --timeout Fail after this many milliseconds Without --until, matches idle, done, or blocked. Without --timeout, waits indefinitely. ``` - **단위는 밀리초**다. Phase 0 fixture(`wait-contract.json`)도 `"type": "integer_ms"`로 정확히 기록되어 있다. - 성공 → `exit 0`, **stdout**에 `{"id":"cli:agent:wait","result":{"agent":{...}}}`. - 타임아웃/대상 부재 → `exit 1`, **stderr**에 에러 JSON(`error.code` = `timeout` / `agent_not_found`). stdout은 비어 있다. - `--until` 생략 시 기본 매칭은 `idle,done,blocked` — **`working`은 매칭되지 않는다.** watcher는 항상 `--until`을 명시한다. - **단일 `wait`는 1개의 최종 스냅샷만 주는 동기 장벽**이다(`herdr-runtime.md` §2-1). 연속 관측은 반복 호출로 구성해야 한다. ### 1-6. Phase 0에서 이미 검증된 판정 벡터 `tests/fixtures/issue_5/watcher/`의 7종과 `expected_policy.decision`은 3인 교차 리뷰를 통과한 계약이다. Phase 2a 엔진의 결정 함수는 **이 7종을 그대로 입력받아 같은 결정을 내야 한다**. | 시나리오 | 기대 결정 | |---|---| | `initial_idle` | `hold` (실행 앵커 없음) | | `tool_running` | `hold` (도구 실행 중) | | `report_pause_15s` | `hold` (매니페스트 없음 — 디바운스만으로 완료 금지) | | `finalized_candidate` | `candidate_approved` | | `blocked_permission` | `hold_blocked` | | `transport_disconnected` | `hold_reconnect` | | `restarted_generation` | `reject_stale` | `dispatch_context` 필드: `job_id`, `attempt_id`, `agent_session_uuid`, `pane_process_generation`, `dispatch_ack`, `execution_anchor_observed`. ### 1-7. Phase 1에서 넘어온 인계 사항 (본 Phase가 게이트) 1. **`preexec_fn` (3인 리뷰어 공통 지적)**: `daemon.spawn`은 `preexec_fn`으로 SIGHUP을 무시하는데 이는 **멀티스레드 프로세스에서 안전하지 않다**. Shadow watcher가 스레드를 쓰면서 `DelegationService.ensure_subscriber_daemon`을 호출하면 이 게이트에 걸린다(§D9). 2. **O-4 (Claude)**: freeze 트리의 `_delegate_py_bin`은 `.venv`를 찾지 못해 paho 없는 시스템 python으로 떨어진다. Phase 2a가 freeze `lib.sh` 헬퍼를 호출하지 않는 한 무해하다(§D9에서 재확인). 3. **연결 지문 하드코딩 필드 목록**: `BrokerConfig` 필드 추가 시 조용히 누락된다. Phase 2a에서 `dataclasses.fields()` 유도로 바꾸거나 커버리지 단정 테스트를 추가한다(§3 P2A-04에 포함). --- ## 2. 설계 결정 (Design Decisions) ### D1. 패키지 배치 ``` .agents/skills/lib_py/loop/ __init__.py # 공개 API 재수출 evidence.py # 증거 튜플 정규화 (watcher → engine 사이의 유일한 자료형) attribution.py # 4중 귀속 + dispatch anchor 판정 (순수 함수) policy.py # 결정 함수: 증거 -> hold|hold_blocked|hold_reconnect|reject_stale|candidate_approved engine.py # 단일 전이 주체 + CAS 트랜잭션 경계 watcher.py # herdr agent wait 래핑, 재연결 백오프, 스냅샷 수집 shadow.py # Shadow 러너: watcher -> policy -> "would-complete" 로깅 (쓰기 없음) store.py # shadow 전용 귀속/증거 저장소 (레지스트리와 분리) ``` - **`policy.py`는 순수 함수여야 한다**(I/O·시계·전역 상태 금지). Phase 0 fixture 7종을 그대로 먹여 테스트하기 위한 필수 조건이다. - **D1(b) 재적용**: `lib_py.loop`는 `lib_py.delegation` **외의** 형제 `lib_py` 모듈을 임포트하지 않는다. 이유는 Phase 1과 동일하다 — freeze 하에서 부모 `lib_py`와 바인딩된 패키지가 다른 트리일 수 있다. AST grep 테스트로 고정한다(§D8). - Python 3.9 호환 필수(`match`, PEP 604 런타임 유니온 금지). 게이트에서 `compileall`. ### D2. 단일 CAS 전이 경계 (P2A-01) **`engine.transition()`이 유일한 상태 writer가 된다.** ```python # lib_py/loop/engine.py class TransitionResult(object): __slots__ = ("committed", "from_status", "to_status", "reason", "record", "notifications") def transition(job_id, request, *, registry_dir, expected_attempt=None, expected_status=None, fields=None, source="unknown") -> TransitionResult: """유일한 상태 전이 주체. 락 안에서 하는 일: load -> CAS 판정 -> (허용 시) 기록 -> 알림 큐잉. 락 밖에서 하는 일: 알림 flush(감사 로그), MQTT 발행. """ ``` CAS 규칙은 `state-transitions.md` §3 표를 그대로 구현한다: | 현재 | 요청 | 조건 | 결과 | |---|---|---|---| | `pending` | `claim`/`started` | 세션·attempt 일치 | `running` | | `running` | `progress`/`permission_required` | — | `running` (메타만 갱신) | | `running` | explicit terminal | 유효 세션 서명 | 요청 terminal | | `terminal` | 동일 terminal 재수신 | — | **No-op 멱등 ACK** | | `terminal` | 상충 신호 | — | **거부 + conflict 감사 기록** | | 임의 | stale attempt/generation | 불일치 | **거부 + stale 경고** | | `running` | `deadline_timeout` | 타임아웃 확인 | `error` | **기존 writer 3곳의 라우팅**: - W1 `update_job_status` → 내부적으로 `engine.transition(request="merge", ...)`를 호출하도록 교체. **시그니처·반환값·예외는 불변**(Phase 1의 G-1~G-4가 이 함수 계약에 걸려 있다). - W2 `pick_pending` → `engine.transition(request="claim", expected_status="pending")`로 교체. 이로써 우회가 사라진다. - W3 `next_seq` → 상태를 바꾸지 않으므로 그대로 두되, 같은 락을 쓰므로 **트랜잭션 시간 예산**에만 포함한다. **롤백 스위치 `MAM_ENGINE_CAS`(기본 `1`)**: `0`이면 W1/W2가 **현재의 무조건 병합 본체**로 분기한다. Phase 1의 `MAM_DELEGATION_PY`와 동일한 패턴이며, legacy 본체는 `_*_legacy`로 보존한다(제거는 Phase 4). ### D3. 락 규율 — 락 안에서는 결정만, I/O는 밖에서 ``` with registry_lock(registry_dir): # 짧게 record = load_job(...) decision = _cas_decide(record, request, expected_attempt, expected_status) if decision.commit: _atomic_write_record(...) # 레코드 1회 쓰기만 허용 pending = decision.notifications # 감사 로그는 "쓰지 않고" 큐에 담는다 # 락 해제 후 _flush_notifications(pending) # 감사 로그 append / status mirror ``` - **금지**: 락 보유 중 `subprocess`, 소켓, MQTT, `herdr` 호출, `time.sleep`. - **허용**: `load_job` 1회 + `_atomic_write_record` 1회. - 감사 로그 mirror(`update_logged_status`/`append_event`)를 락 밖으로 옮긴다. **순서 보장 손실 우려**: 현재는 락 안이라 감사 이력이 직렬화된다. 이를 유지하기 위해 알림에 **CAS가 부여한 단조 `transition_seq`**를 실어 보내고, flush가 늦게 도착해도 순서를 복원할 수 있게 한다. - 이 규율은 문장이 아니라 테스트로 강제한다: 락 획득 중 `subprocess.run`/`socket.socket`/`time.sleep`을 monkeypatch로 감시해 호출 0건을 단정한다(G5). - **저장소에 이미 선례가 있다**: `pick_pending`은 레코드 쓰기를 락 안에서 끝내고 감사 로그 mirror(`update_logged_status`/`append_event`)를 **락 해제 후에** 수행한다(`registry.py:131-145`). D3은 새 패턴을 발명하는 것이 아니라 **이 기존 패턴을 `update_job_status`에도 적용해 일반화**하는 것이다. ### D4. `watcher.py` — `herdr agent wait` 래핑 ```python def wait_once(target, until=("idle", "done", "blocked"), timeout_ms=15000, run=subprocess.run) -> WaitOutcome ``` - `--timeout`은 **밀리초**로 전달한다(§1-5). 초 단위 인자를 받지 않는다 — 단위 혼동을 타입 이름(`timeout_ms`)으로 차단한다. - `--until`을 **항상 명시**한다(생략 시 `working`이 빠지는 기본값에 의존하지 않는다). - `exit 0` → stdout JSON 파싱 → `result.agent` 스냅샷. - `exit 1` → **stderr** JSON 파싱 → `error.code`로 분기: `timeout`(정상적 무이벤트), `agent_not_found`(대상 소멸). - 그 외 exit code / JSON 파싱 실패 / 실행 실패 → `transport_error`. - **재연결 백오프**: `transport_error` 연속 발생 시 1s → 2s → 4s → … → 최대 16s. 백오프 대기는 **락 밖에서만** 한다. 복구 시 **캐시된 스냅샷을 폐기하고 재스냅샷**한다(`transport_disconnected` → `hold_reconnect` 정책과 일치). - watcher는 **판정하지 않는다.** 증거 튜플만 만들어 큐에 넣는다(Phase 0 합의: watcher는 관측, engine은 결정). ### D5. Shadow 모드 — 쓰기 불가를 구조로 보장 (P2A-03) ```python # shadow.py class ShadowRunner: def __init__(self, ..., decide=policy.decide, sink=logging_sink): ... # engine 을 아예 주입받지 않는다 ``` - **ShadowRunner는 `engine.transition`에 대한 참조를 갖지 않는다.** "쓰지 않기로 약속"이 아니라 **쓸 수단이 없다**. 이것이 P2A-03을 문장이 아니라 구조로 만드는 방법이다. - 판정 결과는 `would_complete` 레코드로 **shadow 전용 로그**에만 남는다: `.mam/shadow/.ndjson` (레지스트리와 다른 디렉터리). - 검증(G4): shadow 러너를 시나리오 전량으로 돌린 전후에 **레지스트리 디렉터리 전체의 파일 목록·mtime·SHA-256이 바이트 동일**함을 단정한다. "status를 안 썼다"보다 강한 단정이다. ### D6. 귀속 증거는 shadow 전용 저장소에 (§1-4 결론) `store.py`가 `.mam/shadow/attribution/.json`을 소유한다. 담기는 것: ```json { "job_id": "...", "attempt_id": 1, "agent_session_uuid": "...", "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z", "dispatch_ack": true, "dispatch_ack_at": "...", "execution_anchor_observed": false } ``` - **job 레코드는 건드리지 않는다** → Phase 1의 스키마 불변 계약과 P2A-03을 동시에 만족한다. - `attempt_id`는 Phase 2a에서 **shadow가 자체 부여**한다(디스패치 관측 시 1부터). Phase 2b에서 레코드로 승격할지는 그때 결정한다 — **이 계획서는 레코드 스키마를 바꾸지 않는다.** - `pane_process_generation`은 `::` 형식이며, Phase 0의 정체성 검증(`_validate_observed_herdr_item`)이 이미 이 형식을 강제한다. ### D7. 판정 테스트 벡터 = Phase 0 fixture (P2A-04의 절반) `policy.decide(scenario) -> decision` 이 Phase 0의 7종 시나리오에 대해 `expected_policy.decision`과 **정확히 일치**해야 한다. 신규 fixture를 만들기 전에 **기존 계약을 먼저 만족**시킨다. 이렇게 하면: - 판정 로직이 3인 리뷰를 통과한 계약에 묶인다. - Phase 0의 `validate_watcher_scenario`가 fixture 무결성을 계속 지킨다. 추가 시나리오(P2A-04 요구)는 같은 스키마로 작성하고 **`validate_watcher_scenario`를 통과해야 한다**: `race_double_dispatch`, `attempt_reuse`, `partial_report`, `unknown_status`, `restart_midflight`. ### D8. 트리 일관성 (Phase 1 R1의 교훈 재적용) - `lib_py.loop`는 `lib_py.delegation`만 형제로 임포트한다. 그 외 `lib_py.*` 금지 → AST 테스트. - 루프 프로세스는 shim을 인프로세스로 임포트하지 않으므로 `lib_py.loop`와 `lib_py.delegation`은 부모 `lib_py`를 통해 **같은 트리**로 해석된다. 이를 테스트로 고정한다(`__file__` 접두 비교). - Shadow 러너를 서브프로세스로 띄울 때는 Phase 1의 `_delegation_py`와 **동일한 명시 부트스트랩**을 쓴다. `python -m`은 cwd가 `PYTHONPATH`를 이기므로 금지한다(R1 재발 방지). ### D9. Phase 1 인계 게이트 해소 - **`preexec_fn`**: Shadow watcher가 스레드를 쓸 경우 `daemon.spawn`의 `preexec_fn`이 위험해진다. **Phase 2a에서는 shadow 러너가 데몬을 스폰하지 않는다**(관측만). `ensure_subscriber_daemon` 호출 금지를 grep 테스트로 고정하고, 실제 제거/대체는 Phase 2b 스폰 도입 시 처리한다. - **O-4**: shadow는 freeze `lib.sh` 헬퍼를 호출하지 않으므로 인터프리터 해석 문제에 노출되지 않는다. 호출 0건을 grep으로 고정한다. - **연결 지문 필드 목록**: `dataclasses.fields(BrokerConfig)` 전량이 지문에 포함됨을 단정하는 테스트를 추가한다(필드 추가 시 조용히 누락되는 것을 차단). --- ## 3. 작업 분해 (WBS) — 커밋 5개 ### P2A-00 특성화 선행 (커밋 1: `test(issue-5): phase 2a writer inventory and lock discipline characterization`) - **파일**: `tests/test_issue5_phase2a_loop.py`(신규) - 현행 동작을 **먼저 고정**한다(Phase 1에서 효과가 입증된 순서): - X-01 `update_job_status`가 무조건 병합임을 단정(현행 사실). - X-02 `pick_pending`의 현행 계약 고정: `pending`만 claim하고 동시 claim 시 한 세션만 이긴다(라우팅 후에도 이 성질이 유지되어야 한다). - X-03 락 보유 중 감사 로그 I/O가 **발생함**을 단정(현행 사실 — D3 이후 이 테스트는 반대로 뒤집힌다). - X-04 `next_seq`/`register_job`/`record_outcome` 경로의 현행 부작용 스냅샷. - **완료 기준**: 레거시 코드에서 전부 통과, 576 + 신규 통과. ### P2A-01 엔진·CAS 경계 (커밋 2: `feat(issue-5): single CAS transition boundary for every job-status writer`) - **파일**: `lib_py/loop/{__init__,engine,evidence,attribution}.py`; `mqtt_common.update_job_status`·`registry.pick_pending` 라우팅; `MAM_ENGINE_CAS` 분기 + `_*_legacy` 보존. - **테스트**: `state-transitions.md` §3 표 9행 전부에 대한 전이 테스트, 멱등 재수신, 상충 거부 + conflict 감사, stale attempt 거부, `MAM_ENGINE_CAS=0` 양 경로 parametrize, **락 내 I/O 0건 단정**(subprocess/socket/sleep monkeypatch). - **완료 기준**: Phase 1의 G-1~G-4가 그대로 통과(계약 불변 증명), 전체 스위트 무회귀. ### P2A-02 watcher (커밋 3: `feat(issue-5): herdr agent wait watcher with reconnect backoff`) - **파일**: `lib_py/loop/watcher.py`, `store.py` - **테스트**: 가짜 `run`으로 exit 0/stdout, exit 1/stderr `timeout`, exit 1/stderr `agent_not_found`, 파싱 실패, 실행 실패 5경로; 백오프 수열(1,2,4,8,16 상한)과 **재연결 시 캐시 폐기**; `--timeout`이 **밀리초로 전달**됨을 argv 단정; `--until` 항상 명시됨을 argv 단정. **herdr 실제 호출 0건**(전부 주입된 가짜). - **완료 기준**: 위 통과 + 실제 herdr로 1회 스모크(수동, 보고서 첨부). ### P2A-03 shadow 러너 (커밋 4: `feat(issue-5): shadow watcher that logs would-complete without writing`) - **파일**: `lib_py/loop/{policy,shadow}.py` - **테스트**: Phase 0 시나리오 7종 → `policy.decide`가 `expected_policy.decision`과 일치; 신규 시나리오 5종 추가(`validate_watcher_scenario` 통과 필수); **레지스트리 디렉터리 SHA-256 전후 동일** 단정; `ShadowRunner`가 `engine`/`update_job_status`/`_atomic_write_record`를 참조하지 않음을 AST로 단정. - **완료 기준**: shadow 전량 실행 후 레지스트리 바이트 불변. ### P2A-04 통합·문서 (커밋 5: `docs(issue-5): phase 2a contracts, scenarios and task checklist`) - race/attempt 재사용/부분 리포트/도구 실행/blocked/unknown/재시작 시나리오 실행 결과 기록. - `state-transitions.md`에 구현 매핑 각주, `task.md` P2A-01~04 체크 + 증거, `implementation_plan.md` Phase 2a 상태 갱신, `MAM_ENGINE_CAS` 운영 문서. - Phase 1 인계 3건 처리 상태 명시(§D9). --- ## 4. 게이트 | # | 조건 | 측정 | |---|---|---| | G1 | 기존 **576** 전부 통과 + 신규 전부 통과, skipped 0 | `pytest tests/ -q` (약 13분) | | G2 | 셸 문법·공백 | `bash -n`, `git diff --check` (**범위** 기준) | | G3 | Python 3.9 컴파일 | `compileall .agents/skills/lib_py/loop` | | **G4** | **Shadow가 레지스트리에 0바이트 기록** | 실행 전후 레지스트리 디렉터리 파일 목록·SHA-256 동일 | | **G5** | **락 내 I/O 배제** | 락 보유 중 subprocess/socket/sleep 호출 0건 (monkeypatch 감시) | | G6 | 모든 상태 writer가 CAS 경유 | `_atomic_write_record` 직접 호출자가 engine·`register_job`·`next_seq`뿐임을 AST로 단정 | | G7 | 판정이 Phase 0 계약과 일치 | 시나리오 7종 + 신규 5종 `expected_policy` 일치 | | G8 | 롤백 | `MAM_ENGINE_CAS=0`에서 특성화 통과 + 라이브 스모크 | | G9 | 트리 일관성 | `lib_py.loop`/`lib_py.delegation` 동일 트리, 형제 임포트 0건 | Worker 보고서에는 G1 pytest 요약 원문, G4의 해시 비교 출력, G5의 감시 로그, G7 표를 **그대로** 붙인다. `file:line` 인용은 `grep -n` 출력으로 뒷받침한다. --- ## 5. 롤백 1. **즉시**: `MAM_ENGINE_CAS=0` → W1/W2가 보존된 legacy 무조건 병합으로 분기. Shadow는 애초에 쓰기가 없으므로 프로세스 종료만으로 흔적이 사라진다. 2. **프로세스**: Shadow 러너 종료 + 인메모리 큐 폐기. `.mam/shadow/`는 삭제해도 무해(레지스트리와 분리되어 있음). 3. **커밋 단위**: 5→4→3→2 순 `git revert`. 커밋 1(특성화)은 레거시에서도 통과하므로 남겨도 된다. --- ## 6. 리스크와 완화 | # | 리스크 | 완화 | |---|---|---| | R1 | CAS 도입이 Phase 1의 G-1~G-4 계약을 깬다 | `update_job_status` 시그니처·반환·예외 불변 유지, 두 경로 parametrize | | R2 | 감사 로그를 락 밖으로 빼면서 순서가 뒤바뀐다 | CAS가 부여하는 단조 `transition_seq`를 알림에 실어 순서 복원 | | R3 | `pick_pending` 라우팅이 **이미 올바른** 동시 claim 경쟁을 깨뜨린다 | 라우팅 전에 X-02로 현행 성질을 고정하고, `expected_status="pending"` CAS가 동일 성질을 유지함을 동시 claim 테스트로 검증 | | R4 | `--timeout` 단위 혼동(초 vs ms) | 인자명 `timeout_ms`, argv 단정 테스트 | | R5 | shadow가 실수로 쓰기 경로를 얻는다 | 구조적 차단(engine 미주입) + AST 단정 + 해시 불변 단정 | | R6 | freeze/live 트리 혼재 | D8 (형제 임포트 금지 + 명시 부트스트랩) | | R7 | `preexec_fn` 스레드 비안전 | Phase 2a는 데몬 스폰 없음(grep 고정), 실제 대체는 Phase 2b | | R8 | 실브로커/실herdr 의존 테스트 | 전부 주입된 가짜, 실호출 0건. 라이브 스모크는 수동·정보성 | --- ## 7. 명시적 비범위 - **자동완료 실제 커밋** — Phase 2b. Phase 2a는 `would_complete` 로깅까지만. - job 레코드 스키마 변경(`attempt_id` 등 승격) — Phase 2b. - `run_loop.sh`의 파이썬화 — Phase 3. - 래퍼·`reconcile.sh`의 상태 머신 변경 — 전 Phase 공통 불변. - `preexec_fn` 제거 — Phase 2b. - `pgrep` 다중 PID 정리, Windows — Phase 4 후보. --- ## 8. Worker 인수인계 체크리스트 - [ ] P2A-00 커밋 1: 현행 동작 특성화(W1 무조건 병합, W2 우회, 락 내 I/O 발생)가 **레거시 코드에서** 통과 - [ ] P2A-01 커밋 2: `engine.transition` 단일 경계, W1/W2 라우팅, `MAM_ENGINE_CAS` 양 경로, 락 내 I/O 0건 - [ ] P2A-02 커밋 3: watcher 5경로 + 백오프 + ms 단위 argv 단정 - [ ] P2A-03 커밋 4: Phase 0 시나리오 7종 일치 + 신규 5종, 레지스트리 해시 불변 - [ ] P2A-04 커밋 5: 문서·체크리스트·인계 3건 처리 상태 - [ ] 보고서: G1 원문, G4 해시 출력, G5 감시 로그, G7 표, `grep -n` 인용 - [ ] 커밋 접두: `test(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `docs(issue-5)` --- ## 부록 A. 계획 수립 시 실행한 검증 명령 (재현용) ```bash git log --oneline -1 # 015ab70 .venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 576 grep -rn "_atomic_write_record" --include='*.py' .agents | grep -v "def " # writer 5곳 sed -n '125,155p' .agents/skills/lib_py/delegation/registry.py # pick_pending 우회 sed -n '395,420p' .agents/skills/lib_py/delegation/mqtt_common.py # 락 내 감사 I/O HERDR_SESSION_NAME=multi-agent-mux herdr agent wait --help # --timeout cat tests/fixtures/issue_5/herdr/wait-contract.json # integer_ms grep -rn "MAM_ENGINE_CAS" --include='*.py' --include='*.sh' . # 미존재 .venv/bin/python -c "import json,glob; print(sorted(json.load(open(sorted(glob.glob('.mam/jobs/*.json'))[-1]))))" # 레코드 키 (attempt_id 부재) ```